Doc Page

Dark Mode

Enable dark mode without changing component semantics.

Updated: 2026-06-04 · Owner: docs@nake-ui

Principle

Dark mode is a styling concern. It must not change primitive anatomy, state model, keyboard behavior, ARIA relationships, or adapter lifecycle.

The same Dialog, Select, or Tabs instance should expose the same DOM contract in light and dark mode.

If switching theme remounts roots, changes IDs, resets focus, or recreates open content, it is no longer just a visual concern. The theme layer can change color, border, shadow, and motion intensity; it should not change behavior ownership.

Sources: shared/en/dark-mode

Recommended Setup

Use a top-level attribute or class for theme mode:

<html data-theme="dark">
  ...
</html>

Then define semantic variables:

:root {
  --surface: #ffffff;
  --text: #111111;
  --focus-ring: #2563eb;
}

[data-theme="dark"] {
  --surface: #111111;
  --text: #f8fafc;
  --focus-ring: #93c5fd;
}

Keep primitive selectors independent from the mode switch:

[data-ui="tooltip"][data-slot="content"] {
  background: var(--surface);
  color: var(--text);
}

Prefer mode at the application boundary and primitive state on the primitive node:

<html data-theme="dark">
  <button data-ui="tabs" data-slot="trigger" data-state="active">Overview</button>
</html>

That lets selectors combine theme and state without turning theme classes into semantic truth.

Sources: shared/en/dark-mode

SSR And First Paint

For SSR apps, choose a deterministic initial mode:

  • Read mode from cookie or server state.
  • Inline a tiny mode script only if your app accepts that policy.
  • Avoid rendering different primitive branches for light and dark mode.
  • Keep IDs and open state the same across server and client.

Theme flash is a visual issue. Hydration mismatch is a behavior issue. Fix the latter first.

Common SSR strategies:

  • If a server preference exists, write the initial data-theme from a cookie or server state.
  • If no preference exists, choose a stable default and switch after client activation.
  • If reading prefers-color-scheme, ensure the script only updates the theme attribute and does not branch primitive markup.
  • For layer primitives such as Dialog, Menu, Select, and Combobox, keep closed initial state identical before and after the theme script runs.

Sources: shared/en/dark-mode

Accessibility Checks

Dark mode must preserve:

  • Visible focus rings.
  • Sufficient contrast for text and icons.
  • Distinguishable selected/highlighted/disabled/invalid states.
  • Readable floating content over overlays.
  • Clear backdrop/content separation for Dialog.

Do not rely on hue alone for invalid or selected states.

Use non-color cues too:

  • Focus: outline, ring, or underline.
  • Selected: icon, border, background, plus readable aria-selected state.
  • Invalid: text message, aria-invalid, and error-description linkage.
  • Disabled: reduced affordance plus programmatic disabled state.

Also check high-contrast mode and reduced motion. State should not be understandable only through animation.

Sources: shared/en/dark-mode

Adapter Diff

The adapter should not need dark-mode-specific props. React, Vue, Solid, Svelte, and Vanilla can all read the same CSS variables from the DOM.

If an adapter example imports a theme CSS file, that import should be removable without changing behavior.

Theme packages demonstrate how to read public selectors. They should not require extra wrappers or change primitive slot structure. If deleting theme CSS changes keyboard behavior, ARIA linkage, or form value, behavior and style have been coupled incorrectly.

Sources: shared/en/dark-mode

Pitfalls

  • Branching primitive markup by theme mode.
  • Changing generated IDs when mode changes.
  • Hiding focus rings in dark mode because contrast looked "too bright."
  • Resetting controlled state when a theme provider remounts.
  • Using .dark .is-open as the only open-state source while DOM lacks aria-expanded or data-state.
  • Hiding disabled or invalid text in dark mode and relying only on color.

Sources: shared/en/dark-mode

Verification

After switching theme, check:

  1. Any open Dialog, Popover, Menu, or Select did not close unexpectedly.
  2. Focus remains on the expected element.
  3. aria-controls, aria-labelledby, and aria-describedby still point to existing elements.
  4. data-state, data-selected, and data-highlighted were not replaced by styling classes.
  5. Light and dark screenshots both show focus, selected, disabled, and invalid differences.

For SSR apps, also confirm server HTML and client activation expose the same primitive contract.

Sources: shared/en/dark-mode

Checklist

  • Mode switch does not remount primitive roots.
  • ARIA and public data-* attributes remain unchanged.
  • Focus indicators pass contrast checks.
  • Floating and overlay surfaces remain readable in both modes.
  • Theme switching updates theme state without rebuilding primitive DOM.

Sources: shared/en/dark-mode