Skip to main content

Dark mode

Aidash Components is dark-mode-first — every token has a paired light and dark value. Toggling is one class on <html>. This guide shows the wiring, the pitfalls, and the small polish steps that make the switch feel right.

Class-based, not media
We toggle a .dark class rather than using a media query, so users can override the OS preference. The class strategy also plays nicely with SSR.

Setup

Four steps from a light-only build to a proper light-and-dark app.

Split your tokens

Declare every semantic token twice — once inside :root (light) and once inside .dark.

app/globals.css
css
:root {
  --background:         oklch(1 0 0);
  --foreground:         oklch(0.145 0 0);
  --card:               oklch(1 0 0);
  --muted:              oklch(0.97 0 0);
  --muted-foreground:   oklch(0.556 0 0);
  --border:             oklch(0.92 0 0);
}

.dark {
  --background:         oklch(0.145 0 0);
  --foreground:         oklch(0.985 0 0);
  --card:               oklch(0.18 0 0);
  --muted:              oklch(0.22 0 0);
  --muted-foreground:   oklch(0.68 0 0);
  --border:             oklch(0.26 0 0);
}

Prevent the flash of unstyled content

Inline a tiny script inside <head> so the .dark class lands before the first paint. Without it, users get a white flash before the dark theme applies.

app/layout.tsx
tsx
<script dangerouslySetInnerHTML={{ __html: `
  (function(){
    try {
      var t = localStorage.getItem('theme');
      if (t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
        document.documentElement.classList.add('dark');
      }
    } catch (e) {}
  })();
` }} />

Build a toggle

A tiny client component that reads and writes localStorage plus the <html> class.

components/theme-toggle.tsx
tsx
'use client';

import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const [dark, setDark] = useState<boolean>(false);

  useEffect(() => {
    setDark(document.documentElement.classList.contains('dark'));
  }, []);

  function toggle() {
    const next = !dark;
    setDark(next);
    document.documentElement.classList.toggle('dark', next);
    localStorage.setItem('theme', next ? 'dark' : 'light');
  }

  return (
    <button
      onClick={toggle}
      aria-label={dark ? 'Switch to light mode' : 'Switch to dark mode'}
    >
      {dark ? '🌞' : '🌙'}
    </button>
  );
}

Sync with the OS

If the user hasn't made a choice, follow the system preference and update it live as they flip their OS setting.

hooks/useSystemTheme.ts
tsx
useEffect(() => {
  const stored = localStorage.getItem('theme');
  if (stored) return; // user preference wins

  const media = window.matchMedia('(prefers-color-scheme: dark)');
  const apply = () => document.documentElement.classList.toggle('dark', media.matches);

  apply();
  media.addEventListener('change', apply);
  return () => media.removeEventListener('change', apply);
}, []);

Best practices

Four rules of thumb from years of building interfaces that read well in both modes.

Never use pure black

oklch(0.10 0 0) reads softer than #000. Pure black plus text creates painful contrast on OLED screens.

Reference tokens, not hex

Read --background, --foreground, --border. Every component in Aidash does — flipping the class is enough.

Watch saturated hues

A bright accent that looks great on white can "vibrate" on black. Drop chroma 10–20% for dark contexts.

Test both continuously

Do not "add dark mode later" — build in both modes at the same time. Bugs compound if you don't.

Detecting the mode from JS

Sometimes you need to render different content based on theme (e.g. swap a logo).

hooks/useTheme.ts
tsx
import { useEffect, useState } from 'react';

export function useTheme() {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    setDark(document.documentElement.classList.contains('dark'));
    const observer = new MutationObserver(() => {
      setDark(document.documentElement.classList.contains('dark'));
    });
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
    return () => observer.disconnect();
  }, []);

  return dark ? 'dark' : 'light';
}

Also see