Back to Blogs

Implementing Dark Mode with System Preference Support

Dark mode isn't just aesthetic—it's an accessibility feature and user expectation. But implementing it properly requires more thought than toggling a class. Let's build a dark mode system that handles system preferences, user overrides, and persistence.

The Requirements

A good dark mode implementation should:

  1. Respect system preferences by default
  2. Allow user override (some users want dark mode even if their system is light)
  3. Persist across sessions using localStorage
  4. React to system changes (user changes OS theme while app is open)
  5. Prevent flash (no white flash on dark mode page load)

The Three-State System

Most implementations have two states: light and dark. But we need three:

  • light - Force light mode
  • dark - Force dark mode
  • system - Respect OS preference

This gives users full control while defaulting to their system preference.

Building the Composable

The implementation uses Vue's reactivity system to track the user's preference and the system's preference separately. Then we compute the actual theme to apply.

The key insight is using matchMedia to detect system preference and addEventListener to react to changes. localStorage handles persistence across sessions.

Preventing the Flash

The most important detail is preventing the flash of wrong theme on page load. This requires inlining a small script in the HTML head that runs before Vue hydrates.

The script checks localStorage and sets the class on the document element immediately. No framework overhead, no flash.

Handling System Changes

Users might change their OS theme while your app is open. The matchMedia change event lets you react to this instantly.

When the system preference changes, if the user is in system mode, the theme updates automatically. If they've chosen an explicit preference, it stays locked to their choice.

Usage in Components

Once set up, using dark mode in components is trivial. The composable exposes the current mode and a setter. Components can toggle between light, dark, and system with a single function call.

Styling with Tailwind

Tailwind's dark mode support makes styling straightforward. Just prefix utilities with dark: and you're done. The class strategy means no rebuild needed when toggling themes.

The Result

You end up with a dark mode implementation that:

  • Respects user preferences
  • Persists across sessions
  • Reacts to system changes
  • Never flashes the wrong theme
  • Works seamlessly with Tailwind

All with a clean, reusable composable that any component can import.

Accessibility Matters

Remember: dark mode is an accessibility feature. Users with light sensitivity, eye strain, or who work in low-light environments rely on it. Implement it properly, and your users will notice.

Enjoyed this?

Share it with your network