A theme toggle is not accessible merely because the colours change. For a Next.js SaaS, light, dark, and system preferences need to resolve before the page becomes confusing, survive navigation between public and dashboard routes, and leave text, controls, focus indicators, and browser chrome usable in every resolved state.
Start with three user choices—light, dark, and system—and treat the rendered theme as a separate, derived value. The user owns the preference; the application resolves it against the operating-system signal when the preference is system. That distinction prevents a common bug: persisting “dark” after a user chose “system” while their device happened to be dark at the time.
Choose a theme contract before building the toggle
The smallest useful contract has two values:
type ThemePreference = "light" | "dark" | "system";
type ResolvedTheme = "light" | "dark";
Persist ThemePreference, not the resolved result. On first render, resolve system with prefers-color-scheme; after the application is interactive, subscribe to changes only while the saved preference remains system. A manual light or dark choice should override that system signal until the user deliberately returns to system.
The CSS color-scheme property belongs in this contract too. It tells the browser which schemes the document supports, allowing browser-provided surfaces such as form controls and scrollbars to align with the chosen scheme. It does not replace explicit foreground and background tokens: the W3C notes that authors must set those together when they need reliable legibility.
Make the first render and route boundary intentional
A public marketing route, a login screen, and a dashboard often have different layouts, but they should not briefly disagree about the resolved theme. A flash of light content before a saved dark preference is applied is more than visual polish: it can make focus, contrast, and the browser’s address-bar colour appear to jump while someone is beginning an interaction.
Resolve the initial preference as early as your architecture permits, apply the resolved class or attribute at the document root, and use the same root provider for public, authentication, and dashboard route groups. Keep the public page’s styling distinct if needed; do not create an independent, incompatible preference model for it.
Current Accelerator repository evidence is a useful audit starting point. Its token stylesheet defines :root values and a corresponding .dark token set, while the inspected root layout uses a fixed themeColor. That is not proof of a broken interface, but it is a concrete reason to test whether the deployed theme provider, document root, and browser-chrome metadata all resolve together.
Use semantic tokens, then test the pairs that users actually see
Theme support is fragile when components choose ad-hoc gray values. Name roles such as background, foreground, card, muted, border, destructive, and ring; define a light and dark value for every role that is visible or interactive. Components should consume the role, not decide their own palette.
Do not check only body text on the page background. Review each of these pairs in both resolved themes:
- primary and muted text against the surface behind it;
- links, visited or selected states, and inline validation messages;
- button labels in default, hover, disabled, loading, and destructive states;
- input text, placeholder text, borders, errors, and help text;
- keyboard focus indicators against both the component and nearby page surface;
- charts, status badges, and icons that would otherwise communicate state by colour alone.
W3C accessibility guidance calls for sufficient foreground/background contrast and warns against using colour as the only way to convey information. That means an error needs readable text or an icon and programmatic association in addition to its red token; a chart needs labels, patterns, or values where colour carries meaning.
Theme-state acceptance checklist
Use this as the original asset for a release check. Run it in a real browser with keyboard navigation, not only in a design review.
- Set the operating system to light. Open a public route with no saved preference; it resolves light before meaningful content is read.
- Set the operating system to dark. Repeat the same first-load check and inspect browser chrome, scrollbars, and native controls.
- Select light manually, reload, and navigate public → login → dashboard. The preference persists and no route resets it.
- Select dark manually, reload, and repeat the same route sequence.
- Select system, change the operating-system preference, and confirm the resolved theme changes without replacing the stored preference with a fixed value.
- Tab through navigation, menu controls, forms, dialogs, destructive actions, and dashboard widgets in both resolved themes. Every focus location remains obvious.
- Trigger empty, loading, validation-error, permission-error, and success states. Check every semantic token pair rather than only the happy path.
- Check reduced-motion and forced-colors modes where the supported browsers expose them. Do not suppress user colour adjustments casually with
forced-color-adjust: none. - Zoom text and use a narrow viewport. Theme changes must not hide labels, controls, or state messages behind a responsive breakpoint.
Set browser chrome from the resolved theme
theme-color affects browser UI, but a single static value cannot express both system outcomes. Next.js supports media-specific viewport theme-colour entries, which is useful for a system-aware initial document. If your application also supports a persisted manual override, verify the result in target browsers after the client applies that override; do not assume a static metadata declaration automatically tracks client state.
export const viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#10151c" },
],
};
This is a framework pattern, not a complete theme system. The root class or data attribute, semantic tokens, persistent preference, and acceptance checks still carry the application behaviour.
Keep settings semantics visible to keyboard and assistive-technology users
The control that changes a visual preference is still an application setting. Give it a visible label, announce the current choice in text, and use native radio inputs or an equivalent accessible radio-group pattern for three mutually exclusive options. A pair of unlabeled sun and moon icons cannot represent system, and it leaves the current state dependent on visual interpretation.
For a compact menu, the trigger should expose its name and current value—for example, “Theme: System”—before it opens. The list should make the selected option programmatically available, preserve keyboard operation, and return focus predictably when it closes. Do not use a switch for light/dark/system: a switch expresses an on/off state, while this setting has three choices with different persistence behavior.
Also test the theme control where its result is most consequential: a sign-in page, an account menu, a modal dialog, and a dashboard data screen. A selector that works in the public header but becomes clipped, loses focus visibility, or resets after authentication has not satisfied the route-wide contract.
Separate preference testing from contrast testing
These checks answer different questions. Preference testing asks whether the right resolved state appears at the right time and remains stable after navigation. Contrast testing asks whether each foreground/background pair remains legible in that state. Passing one does not demonstrate the other.
Build a small visual regression route containing the semantic roles your product uses: page, card, primary action, muted helper text, input, disabled input, error, success, dialog, focus ring, and selected navigation. Capture it in light and dark states. Then pair that snapshot with keyboard checks and a manual zoom check. This gives a solo team a bounded review surface without claiming complete accessibility coverage.
Failure modes worth catching before launch
- Toggle-only testing: the settings screen changes but a dashboard route mounts outside the provider.
- Resolved-state persistence: system mode becomes permanently dark or light after the first visit.
- Token gaps: page text works, but muted labels, focus rings, or destructive controls disappear in one scheme.
- Chrome mismatch: the page is dark while inputs, scrollbars, or browser UI retain a light treatment.
- Colour-only meaning: success, error, selected, or unavailable states cannot be understood when colour perception changes.
- Forced-colors override: decorative styles prevent a user’s high-contrast environment from doing its job.
Keep the decision small and testable
You do not need a large design-system rewrite to make themes dependable. Establish one preference contract, apply one resolved root state across route groups, make tokens semantic, and test the interactions where a mismatch matters. A theme system earns trust when a developer can change device preference, sign in, move through the dashboard, and keep reading and navigating without having to re-orient.



