Doc Page
Dark Mode
Enable dark mode without changing component semantics.
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-themefrom 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-selectedstate. - 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-openas the only open-state source while DOM lacksaria-expandedordata-state. - Hiding disabled or invalid text in dark mode and relying only on color.
Sources: shared/en/dark-mode
Verification
After switching theme, check:
- Any open Dialog, Popover, Menu, or Select did not close unexpectedly.
- Focus remains on the expected element.
aria-controls,aria-labelledby, andaria-describedbystill point to existing elements.data-state,data-selected, anddata-highlightedwere not replaced by styling classes.- 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