← Back to gallery
CSS

Dark Mode Token Transition Fallback

Theme toggle that animates color tokens across light → dark via CSS custom-property transitions, with a stale-state guard that snaps to the new theme if the tab was hidden mid-animation. Three transition lengths from quick to deliberate.

dark-modecss-custom-propertiescolor-transitiontheme-togglepage-visibilitydata-themeprefers-reduced-motion

Token transition · safe vs. transition: all caveat

Dark Mode Token Transition Fallback

Two production patterns for animating CSS-token theme swaps — Token Swap (per-property keyframes that animate only colour tokens, leaving layout untouched) and Transition All Caveat (the same swap but layout properties get pulled along for the ride, causing the toggle and panel to wobble between states).

Per-property keyframes · layout preserved

Token Swap

Each token (panel bg, text colour, toggle track, button fill) animates on its own keyframe — colour properties only. Layout stays constant: padding, width, margin never interpolate, so the document never re-flows during the swap. This is the recommended fallback when prefers-color-scheme support varies and you still want a visible cross-fade between modes.

  • per-property keyframe
  • no transition: all
  • layout preserved

Layout properties pulled along · panel wobbles

Transition All Caveat

Same colour swap, but the keyframes ALSO interpolate padding (10px → 18px) and button width (56px → 72px) — what transition: all would do if a designer changed those tokens between modes. The panel resizes mid-cycle and the button visibly wobbles. This is the failure mode the safe variant exists to avoid.

  • transition: all
  • layout shift
  • caveat

Token transition inspector

Token Swap

  • per-property keyframe
  • no transition: all
  • layout preserved

Each token (panel bg, text colour, toggle track, button fill) animates on its own keyframe — colour properties only. Layout stays constant: padding, width, margin never interpolate, so the document never re-flows during the swap. This is the recommended fallback when prefers-color-scheme support varies and you still want a visible cross-fade between modes.

Helped you ship something? 🐟 Send my cat a churu

/* Per-property keyframes animate ONLY colour tokens; padding, width and margin never interpolate.
   Production pattern — JS toggles a class / data-attribute on root,
   CSS transitions interpolate from light to dark. The transition list
   is EXPLICIT (colour properties only) so layout never animates. */

.theme-panel {
  background: var(--theme-light-bg);
  color: var(--theme-light-text);
  border-color: rgba(15, 23, 42, 0.1);
  /* SAFE: name every colour property explicitly. */
  transition:
    background-color 1.00s ease-in-out,
    color 1.00s ease-in-out,
    border-color 1.00s ease-in-out;
}

.theme-panel[data-mode="dark"] {
  background: var(--theme-dark-bg);
  color: var(--theme-dark-text);
  border-color: rgba(248, 250, 252, 0.08);
}

.theme-toggle-thumb {
  /* SAFE: transform is composited — no layout cost. */
  transition: transform 1.00s ease-in-out;
}
.theme-toggle-thumb[data-mode="dark"] {
  transform: translateX(16px);
}

@media (prefers-reduced-motion: reduce) {
  .theme-panel,
  .theme-toggle-thumb { transition: none; }
}

How to make this

A dark-mode token transition fallback swaps CSS custom-property colors through a real toggle and transitions only color-related properties, never transition: all.

html
<div class="theme-swap">2  <input id="theme-swap-toggle" class="theme-swap__input" type="checkbox" />  <label class="theme-swap__toggle" for="theme-swap-toggle">    <span>Dark mode</span>    <span class="theme-swap__track" aria-hidden="true">      <span class="theme-swap__thumb"></span>    </span>  </label>  <section class="theme-swap__panel">    <h2>Token preview</h2>    <p>Only color tokens transition. Layout values stay fixed.</p>    <button type="button">Continue</button>  </section></div> <style>.theme-swap {18  --panel-bg: #f8fafc;  --panel-fg: #0f172a;  --muted: #64748b;  --track: #cbd5e1;  --button: #2563eb;  display: grid;  gap: .8rem;  max-width: 22rem;}.theme-swap__input {  position: absolute;  opacity: 0;}.theme-swap__toggle {  display: inline-flex;  align-items: center;  gap: .75rem;  color: var(--panel-fg);}.theme-swap__track {  position: relative;  width: 2.75rem;  height: 1.35rem;  border-radius: 999px;  background: var(--track);43  transition: background-color .28s ease;}.theme-swap__thumb {  position: absolute;  inset: .18rem auto .18rem .18rem;  width: .99rem;  border-radius: 50%;  background: #fff;  transition: transform .28s ease;}.theme-swap__panel {  padding: 1rem;  border-radius: 12px;  background: var(--panel-bg);  color: var(--panel-fg);  border: 1px solid color-mix(in srgb, var(--panel-fg) 14%, transparent);  transition: background-color .28s ease, color .28s ease, border-color .28s ease;}61.theme-swap__panel p { color: var(--muted); }.theme-swap__panel button {  background: var(--button);  color: #fff;  border: 0;  border-radius: 8px;  padding: .5rem .75rem;  transition: background-color .28s ease;}70.theme-swap__input:checked ~ .theme-swap__toggle,.theme-swap__input:checked ~ .theme-swap__panel {  --panel-bg: #0c1220;  --panel-fg: #e2e8f0;  --muted: #94a3b8;  --track: #1e3a5f;  --button: #60a5fa;}.theme-swap__input:checked + .theme-swap__toggle .theme-swap__thumb {  transform: translateX(1.4rem);}.theme-swap__input:focus-visible + .theme-swap__toggle {  outline: 2px solid #60a5fa;  outline-offset: 4px;}85@media (prefers-reduced-motion: reduce) {  .theme-swap__track,  .theme-swap__thumb,  .theme-swap__panel,  .theme-swap__panel button { transition: none; }}</style>

Annotated snippet

  1. Line 2A real checkbox gives the theme control keyboard and form semantics. Production code can persist the value in localStorage, but the control itself should remain operable without replacing it with a div.
    PitfallHow do I make a dark mode toggle accessible?

    Use a native checkbox or button with an accurate label, keep focus-visible styling on the visible control, and persist the preference separately. The visual token swap should not be the only state indicator.

  2. Line 18Tokens live on the wrapper as custom properties. Light mode is the default, so unsupported or no-JS cases start from a readable theme.
    PitfallWhat is the browser fallback for CSS token theme swaps?

    CSS custom properties are broadly supported in modern browsers. If you rely on newer selectors like :has(), provide a class or data-attribute fallback; this snippet avoids :has() by using sibling selectors.

  3. Line 43Only background-color transitions on the track. Naming the property is the fallback pattern: it blocks accidental animation of width, padding, left, or other layout values.

    Same target colors on both. The before track flips between light and dark at the halfway mark of the loop (steps timing — what an untransitioned theme swap looks like). The after track shares the same keyframe with ease timing, so the same swap reads as a smooth cross-fade.

    PitfallWhich properties should a theme transition include?

    List color-related properties explicitly: background-color, color, border-color, fill, stroke, and sometimes box-shadow. Keep layout and positioning properties out of the transition list.

  4. Line 61The panel transition is explicit and color-only. transition: all looks convenient until a mode-specific spacing or border-radius token starts animating layout.

    Both panels animate the same color swap. The before keyframe also pulls width and padding into the cycle (what transition: all silently allows); the after keyframe touches only background, so the panel size stays stable.

    PitfallWhy is transition: all risky for dark mode?

    Theme changes often touch more than color over time. If transition: all is present, later width, padding, margin, border-radius, or left changes can animate and create layout shift during a simple mode swap.

  5. Line 70The checked selector swaps tokens for both the visible toggle and the panel. Because the same variables drive several descendants, the theme stays synchronized without duplicate declarations.
  6. Line 85Reduced motion removes interpolation but not the theme state. Users still get the selected light or dark colors immediately.
    PitfallHow should prefers-reduced-motion affect theme transitions?

    Disable the interpolation and apply the target tokens instantly. Reduced motion should not force light mode, dark mode, or a different persisted preference; it should only remove the animated cross-fade.

Notes

Overview

Dark mode theme switching where color tokens (CSS custom properties) animate across the swap via transition: background-color, color, ... declarations. A stale-state guard listens for the Page Visibility API and snaps to the new theme if the tab was hidden mid-transition.

When to use it

Reach for token-transition fallback on user-facing theme toggles where the visual continuity reduces jarring. Skip the animated transition when the change happens during a load (theme-from-system on first paint) — the transition would animate from the wrong initial state. Skip it for theme switches that depend on actively-displayed media (use a non-animated swap to avoid color flicker on video).

How it works

Define color tokens as CSS custom properties on :root and :root[data-theme="dark"]. Toggle the data-theme attribute to swap themes. For the cross-fade, apply html { transition: background-color 240ms ease, color 240ms ease } plus matching transitions on common surface elements (cards, buttons, borders). The custom properties themselves are not animatable directly — only properties that use the custom properties (background-color, color, border-color, fill) are. The stale-state guard listens for document.visibilitychange and, if the tab was hidden mid-transition, calls document.documentElement.style.transition = 'none' to snap to the final theme.

Production gotchas

Applying the transition on every element produces a cascade of paints; restrict the transition selector to the root + named surfaces only. Tokens used in box-shadow do not transition smoothly when shadow color is changing — the browser interpolates only between identical shadow geometries. For first-paint, the transition must be disabled until after hydration or the user sees the light theme briefly flash before the dark theme animates in. Use a data-no-transitions attribute on <html> at boot, removed after the first idle callback.

Accessibility

Honor prefers-color-scheme at first paint — only animate the transition when the user explicitly clicks the toggle, not when the system preference changes underneath the page. Under prefers-reduced-motion: reduce drop the transition entirely (instant theme swap) since color cross-fades can be uncomfortable for users sensitive to contrast changes. Verify all token pairs meet WCAG AA contrast in both themes — the swap should not introduce contrast regressions.

References

Implementation depth

Theme transitions are safest when tokens change, not individual components. Switch data-theme or root variables once, then let surfaces consume color custom properties from a single source of truth.

Beware page visibility and first paint. A theme transition that runs before hydration can flash the wrong scheme, so persist the chosen theme early and disable decorative color interpolation under reduced motion.