The current boundary: paired colors and images
An implementation article published in May 2025 describes light-dark() as color-only. That limit is out of date. The MDN page captured for this guide on September 8, 2026 documents either two color values or two image values. The same used-color-scheme mechanism can select a gradient pair or a pair of text or surface colors.
:root {
color-scheme: light dark;
/* Paired colors */
--surface: light-dark(#f7f8f5, #151714);
--text: light-dark(#20231f, #f1f3ee);
/* Paired images */
--page-wash: light-dark(
linear-gradient(#ffffff, #f3f4ef),
linear-gradient(#1b1e1a, #111310)
);
}
body {
color: var(--text);
background-color: var(--surface);
background-image: var(--page-wash);
}Don't turn a value function into a theme engine
The function can't select arbitrary spacing, typography, motion, layout, DOM, or component behavior, and it doesn't save a preference. Keep those decisions in the application mechanisms that own them.
Dark mode crosses several layers. Treating them as one setting makes failures hard to route. The token source defines the authoritative light and dark values for a role such as surface.default. A transformation turns that decision into CSS. Then a root element or nested scope establishes the used color scheme. Finally, a component consumes the semantic property and produces an observable result.
| Decision | Owner | Evidence to inspect | Typical correction owner | |
|---|---|---|---|---|
| Source tokens | Which light and dark values express this semantic role? | Design-system authority | Approved token record and mode pair | Design-system owner |
| Generated CSS | How is that pair represented for the browser? | Token transformation pipeline | Generated custom property and build artifact | Token pipeline owner |
| Scheme control | Which scheme is used here, and why? | Consuming application | Root setting, app choice, system path, and nested scope | Application owner |
| Observed consumer | What computed and rendered value reaches the surface? | Component implementation | DevTools result, visual state, and native-control behavior | Component or application owner |
The distinction between tokens and CSS variables matters here. Tokens capture source decisions that may serve several platforms; CSS custom properties are one web representation of them. A successful browser edit doesn't prove that the source record or generated artifact is correct. A correct token export doesn't prove that the application established the intended scheme either.
Choose the control model from the requirement
Start with the preference contract, not the newest syntax. Does the product follow the operating system, offer an explicit choice, contain independently themed subtrees, or combine those paths? The answer determines who should own color-scheme.
| Scheme owner | Use it when | Main consequence | Keep selectors for | |
|---|---|---|---|---|
| System-only | The root advertises light dark; the user agent resolves the preference. | The product deliberately follows the operating-system setting and has no app override. | The operating-system preference determines the used scheme. | Fallbacks, unsupported browsers, or non-paired behavior. |
| Explicit app choice | An app-controlled root attribute or class sets color-scheme: light or dark. | The user can override the system and the application persists that choice. | The app must restore the choice according to its rendering contract. | State storage, initial document setup, and behavior beyond paired values. |
| Scoped subtree | A nested container establishes its own color-scheme. | A preview, editor canvas, embedded panel, or inverted region must differ from its parent. | Descendants can resolve another branch without changing the page root. | Local assets, exceptional components, and boundaries where inheritance should stop. |
| Hybrid | The root follows the system by default, then an explicit app choice overrides it. | The product offers System, Light, and Dark choices. | Both the system path and explicit override path become supported states. | Persistence, initial rendering, and exceptions that cannot be expressed as paired values. |
For an explicit choice, color-scheme controls branch selection. A class such as .dark is one way to target that declaration. The paired custom properties can stay in one place because their values resolve against the resulting used scheme.
Map semantic roles without collapsing the token layers
Keep primitive values separate from semantic roles and component consumers. Primitives describe the palette; semantic properties describe intent. Components consume those semantic names, so a card doesn't need to know that its background happens to be a particular green in one mode.
:root {
color-scheme: light dark;
/* Primitive palette values */
--sage-050: #f3f4ef;
--sage-900: #171a16;
--ink-900: #20231f;
--ink-050: #f1f3ee;
--line-light: #d7dbd2;
--line-dark: #3a4038;
/* Semantic roles with paired mode values */
--background: light-dark(var(--sage-050), var(--sage-900));
--foreground: light-dark(var(--ink-900), var(--ink-050));
--border: light-dark(var(--line-light), var(--line-dark));
}
.card {
color: var(--foreground);
background: var(--background);
border: 1px solid var(--border);
}The component still requests --background, --foreground, and --border. It doesn't select a mode or reach into the primitive palette. If the dark border is wrong across many consumers, inspect the semantic pair. If only one card is wrong, inspect that component's cascade before changing the shared token.
Inspect a complete semantic set before mapping it
Review the role names and mode pairs first. Then choose one low-risk role for the runtime migration instead of converting the entire palette at once.
Implement automatic, explicit, and nested control
The pattern below supports a System, Light, and Dark preference. The default root declaration permits both schemes, so the system preference can participate. An explicit application choice narrows the root to one scheme. A nested preview can establish another without rewriting every token.
/* Default: allow the user agent to use the system preference. */
:root {
color-scheme: light dark;
--background: light-dark(#f7f8f5, #151714);
--foreground: light-dark(#20231f, #f1f3ee);
--border: light-dark(#d8ddd4, #394038);
--focus-ring: light-dark(#536b3e, #b6d58d);
}
/* Explicit app choices. The app owns this attribute and its persistence. */
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
/* A bounded preview can differ from the surrounding page. */
.theme-preview[data-preview-theme="light"] {
color-scheme: light;
}
.theme-preview[data-preview-theme="dark"] {
color-scheme: dark;
}
body {
color: var(--foreground);
background: var(--background);
}
input,
button,
.card {
color: var(--foreground);
background-color: var(--background);
border-color: var(--border);
}
:focus-visible {
outline: 2px solid var(--focus-ring);
}Include native controls in the test plan
color-scheme also affects interface surfaces rendered by the user agent. A custom card can look correct while inputs, selects, scrollbars, or other user-agent surfaces don't match the intended mode.
CSS doesn't handle preference persistence. Your application must decide where the user's choice lives and when to apply it. The implementation varies by framework and rendering model, so this guide doesn't present one storage script or initial-render strategy as universal.
Migrate one semantic role at a time
Don't begin by replacing every .dark declaration. First, identify which duplicated declarations express a simple light and dark pair. Choose a low-risk role such as a secondary surface or border, then record the expected result before editing.
- 1
Choose one semantic role
Name the role, its authoritative light and dark values, and the source version. Don't start with a role that has many undocumented exceptions.
- 2
Name every intended consumer
List representative components and surfaces that should change. Also record the protected surfaces that must remain unchanged.
- 3
Declare the scheme owner
Record whether the root, an explicit app preference, a nested scope, or a hybrid path determines the used color scheme.
- 4
Write the expected values first
For each supported path, record the expected computed value before changing the CSS. This prevents the current rendering from rewriting the requirement.
- 5
Convert the role
Put the paired values in the semantic custom property. Leave unrelated selector rules in place until you've classified their responsibilities.
- 6
Observe and decide
Record the computed value and rendered result separately. Mark the migration accept, revise, or retain selectors, and assign an owner to every exception.
| What to record | Example entry | Evidence status | Mismatch owner | |
|---|---|---|---|---|
| Semantic role | Stable purpose-driven name | surface.default to --background | Confirm against the current source record | Design-system owner |
| Mode values | Authoritative light and dark values plus source version | Light #f7f8f5; dark #151714; source v12 | Confirm both values in the generated artifact | Design-system or pipeline owner |
| Scheme owner | Root, app preference, nested scope, or hybrid | System default with root override | Inspect the effective declaration for each path | Application owner |
| Consumers | Representative components expected to change | Page, card, dialog, input | Record computed and rendered results per consumer | Component owners |
| Protected surfaces | Consumers that must not change | Inverted logo panel and chart canvas | Compare before and after under both schemes | Named surface owner |
| Expectation | Expected computed value for each path | Dark app choice resolves #151714 | Write before modifying CSS | Test owner |
| Observation | Computed value and rendered result, recorded separately | Computed value matches; input rendering differs | Attach the actual observation and environment | Application owner |
| Exception | Reason, scope, review condition, and disposition | Required browser retains selector path | Record supporting evidence and review condition | Browser-policy owner |
Keep the exceptions visible
Classify the remaining selectors instead of dismissing them as legacy. They may encode compatibility policy or behavior that light-dark() can't express. Remove genuine duplication only after verification.
- Unsupported-browser requirements: keep a tested fallback strategy that matches the project's actual support policy. Don't infer support from one development browser.
- Non-paired behavior: retain application logic or selectors for layout, visibility, motion, component variants, and interaction changes.
- Separate visual assets: a paired CSS image may work for gradients or other CSS images, but logos, illustrations, video, canvas content, and inline asset rules may need their existing mechanism.
- High-contrast requirements: treat forced colors, custom high-contrast modes, and accessibility-specific adaptations as their own contract. Don't assume a light or dark pair covers them.
- Local inversions and previews: keep bounded scope rules when a subtree intentionally differs from the application root.
- Project-specific exceptions: record why the exception exists, who owns it, which surfaces it affects, and what evidence would allow its removal.
/* Baseline for the project's fallback policy. */
:root {
--background: #f7f8f5;
--foreground: #20231f;
}
/* Upgrade only when the function is supported. */
@supports (color: light-dark(white, black)) {
:root {
color-scheme: light dark;
--background: light-dark(#f7f8f5, #151714);
--foreground: light-dark(#20231f, #f1f3ee);
}
}A fallback is a tested policy, not a reassuring declaration
Custom properties and unsupported values can fail later during value resolution than a stylesheet inspection suggests. Test the final consuming property in every required browser. Don't accept a fallback merely because both declarations appear in the stylesheet.
Run an expectation-first acceptance matrix
Write down expectations before opening the page. Then capture the computed custom property, computed consuming property, and rendered result as separate observations. A variable may contain the intended expression while a later override changes the component. Even a correct computed color can sit beside an incorrect asset or native control.
| Expected result | Computed evidence | Rendered evidence | Disposition or exception | |
|---|---|---|---|---|
| System light, app set to System | Light branch on the root and ordinary descendants | Record used scheme, computed token, and consuming property | Inspect the named surfaces under a light system preference | Accept or route mismatch |
| System dark, app set to System | Dark branch on the root and ordinary descendants | Record used scheme, computed token, and consuming property | Inspect the named surfaces under a dark system preference | Accept or route mismatch |
| System dark, app set to Light | Explicit app choice wins on the root | Record restored choice, root scheme, and computed value | Inspect initial and stable light rendering | Accept or revise app control |
| System light, app set to Dark | Explicit app choice wins on the root | Record restored choice, root scheme, and computed value | Inspect initial and stable dark rendering | Accept or revise app control |
| Light root, nested dark scope | Only the bounded subtree uses the dark branch | Record the scope declaration and child computed value | Compare the subtree with adjacent protected surfaces | Accept or revise scope |
| Dark root, nested light scope | Only the bounded subtree uses the light branch | Record the scope declaration and child computed value | Compare the subtree with adjacent protected surfaces | Accept or revise scope |
| Representative semantic consumers | Page, card, dialog, text, border, focus, and status roles follow their recorded contracts | Record final properties for every named consumer | Inspect default, interactive, and status states | Accept or assign component correction |
| Native controls | Controls follow the intended supported scheme | Record the effective scheme and any inspectable control properties | Inspect inputs, selects, checkboxes, scrollbars, and focus behavior as applicable | Accept or record a platform exception |
| Fallback browser path | The documented fallback remains usable and internally consistent | Record final consuming properties in each required browser | Inspect rendered content and controls without the enhanced path | Retain fallback or revise policy |
| Non-color assets and behavior | Each item follows its separately declared rule | Record asset source, visibility state, and controlling selector or logic | Inspect the asset and behavior in every supported mode | Keep the separate mechanism or migrate explicitly |
Trace failures in ownership order. Confirm the source pair first, then inspect the generated representation, scheme controller, cascade, and consumer. This prevents a local override from prompting an unnecessary change to a shared token.
Where Identity Forge fits
Identity Forge supplies upstream design decisions, including 28 semantic light and dark color roles and CSS-oriented exports. Those paired roles are suitable inputs for this migration. The consuming application still owns the control model, selector conversion to light-dark(), preference persistence, browser-support policy, cascade resolution, unrelated assets and behavior, and validation of the rendered result.
The two sides have distinct jobs. The design kit remains authoritative for what roles such as --background, --foreground, --primary, and --border mean in each mode. The consuming application remains authoritative for how those values reach a particular browser and surface.
Make one bounded decision
Choose one low-risk semantic pair and record its source values, consumers, protected surfaces, scheme owner, fallback requirement, and expected matrix results. Convert the role, run every supported preference and scope path, then mark it accept, revise, or retain selectors. Don't convert the next role until the first record contains real observations instead of assumptions.
Sources
- light-dark() CSS function - MDN Web Docs - Mozilla: MDN documents how
light-dark()selects through the used color scheme, accepts paired color or image values, and provides current compatibility information. - color-scheme and light-dark() - Medium: The article demonstrates system-following and explicitly controlled theming with semantic custom properties while making an older color-only limitation claim.
- The developer's guide to design tokens and CSS variables - Penpot: Penpot distinguishes platform-agnostic design-token decisions from the CSS custom properties that implement them in the browser.
- Design Tokens To Dark Mode - Frank Congson: The project note shows light and dark semantic token sets mapped from core values and then connected to applied theme tokens.
- Design Tokens - Web Awesome: Web Awesome documents token variables across root, light, dark, and inverted contexts, demonstrating scoped theme consumers.
- Visual modes - Cloudscape Design System: Cloudscape treats light and dark modes, user choice, mode-aware custom styles, and visual assets as deliberate product concerns.
- Design Tokens - NYS Design System: The NYS Design System separates primitive, semantic, and theme tokens and directs components toward purpose-driven roles rather than raw palette values.
- Semantic color tokens explained: Identity Forge publishes 28 semantic light and dark color roles and explains role-based names such as background, foreground, primary, border, and ring.