Get started

CSS light-dark() with design tokens: preserve semantic roles and theme control

Use light-dark() to pair the light and dark values of a semantic color token. Components can keep consuming names such as --background, --foreground, and --border while each value responds to the element's used color scheme. The function can reduce duplicated token declarations, but it doesn't choose your theme policy, persist a user's preference, switch component behavior, or validate the result.

Updated September 8, 2026

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);
}
Illustrative CSS using the color and image forms documented by MDN.

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.

Separate the four authorities before changing CSS

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.

DecisionOwnerEvidence to inspectTypical correction owner
Source tokensWhich light and dark values express this semantic role?Design-system authorityApproved token record and mode pairDesign-system owner
Generated CSSHow is that pair represented for the browser?Token transformation pipelineGenerated custom property and build artifactToken pipeline owner
Scheme controlWhich scheme is used here, and why?Consuming applicationRoot setting, app choice, system path, and nested scopeApplication owner
Observed consumerWhat computed and rendered value reaches the surface?Component implementationDevTools result, visual state, and native-control behaviorComponent or application owner
Each layer answers a different question. Record its owner before migrating a token.

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 ownerUse it whenMain consequenceKeep selectors for
System-onlyThe 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 choiceAn 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 subtreeA 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.
HybridThe 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.
Control-model decision table

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);
}
Illustrative consuming-application CSS. The values are examples, not an Identity Forge export.

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);
}
Illustrative consuming-application pattern: system by default, explicit root override, and an independently scoped preview.

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. 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. 2

    Name every intended consumer

    List representative components and surfaces that should change. Also record the protected surfaces that must remain unchanged.

  3. 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. 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. 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. 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 recordExample entryEvidence statusMismatch owner
Semantic roleStable purpose-driven namesurface.default to --backgroundConfirm against the current source recordDesign-system owner
Mode valuesAuthoritative light and dark values plus source versionLight #f7f8f5; dark #151714; source v12Confirm both values in the generated artifactDesign-system or pipeline owner
Scheme ownerRoot, app preference, nested scope, or hybridSystem default with root overrideInspect the effective declaration for each pathApplication owner
ConsumersRepresentative components expected to changePage, card, dialog, inputRecord computed and rendered results per consumerComponent owners
Protected surfacesConsumers that must not changeInverted logo panel and chart canvasCompare before and after under both schemesNamed surface owner
ExpectationExpected computed value for each pathDark app choice resolves #151714Write before modifying CSSTest owner
ObservationComputed value and rendered result, recorded separatelyComputed value matches; input rendering differsAttach the actual observation and environmentApplication owner
ExceptionReason, scope, review condition, and dispositionRequired browser retains selector pathRecord supporting evidence and review conditionBrowser-policy owner
Copyable source-to-runtime migration worksheet

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);
  }
}
Illustrative progressive enhancement using standard CSS feature-query syntax. Verify it against the browsers and rendering paths your product supports.

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 resultComputed evidenceRendered evidenceDisposition or exception
System light, app set to SystemLight branch on the root and ordinary descendantsRecord used scheme, computed token, and consuming propertyInspect the named surfaces under a light system preferenceAccept or route mismatch
System dark, app set to SystemDark branch on the root and ordinary descendantsRecord used scheme, computed token, and consuming propertyInspect the named surfaces under a dark system preferenceAccept or route mismatch
System dark, app set to LightExplicit app choice wins on the rootRecord restored choice, root scheme, and computed valueInspect initial and stable light renderingAccept or revise app control
System light, app set to DarkExplicit app choice wins on the rootRecord restored choice, root scheme, and computed valueInspect initial and stable dark renderingAccept or revise app control
Light root, nested dark scopeOnly the bounded subtree uses the dark branchRecord the scope declaration and child computed valueCompare the subtree with adjacent protected surfacesAccept or revise scope
Dark root, nested light scopeOnly the bounded subtree uses the light branchRecord the scope declaration and child computed valueCompare the subtree with adjacent protected surfacesAccept or revise scope
Representative semantic consumersPage, card, dialog, text, border, focus, and status roles follow their recorded contractsRecord final properties for every named consumerInspect default, interactive, and status statesAccept or assign component correction
Native controlsControls follow the intended supported schemeRecord the effective scheme and any inspectable control propertiesInspect inputs, selects, checkboxes, scrollbars, and focus behavior as applicableAccept or record a platform exception
Fallback browser pathThe documented fallback remains usable and internally consistentRecord final consuming properties in each required browserInspect rendered content and controls without the enhanced pathRetain fallback or revise policy
Non-color assets and behaviorEach item follows its separately declared ruleRecord asset source, visibility state, and controlling selector or logicInspect the asset and behavior in every supported modeKeep the separate mechanism or migrate explicitly
Run every supported row for each migrated semantic role. Replace the observation prompts with actual evidence.

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