Color & Theme Transitions: Colour as Part of the Motion System

Colour changes are the most frequent animations in any interface. Every hover, focus, press, selection, validation state and theme toggle changes a colour, often many times a minute. They are also the animations most often handled ad hoc: a hard-coded darker hex here, a transition: all there, a dark theme maintained as a second copy of every token. This guide, part of CSS-Only Micro-Interactions & Animations, treats colour as a system: states derived from tokens instead of listed, themes expressed as pairs instead of copies, gradients animated through their typed inputs, and theme switches that feel deliberate without costing frames.

Prerequisites — this guide assumes you can already:

  • Define and use custom properties as design tokens (see CSS Custom Properties Architecture).
  • Write transitions and understand per-state timing.
  • Read colour notations beyond hex: rgb() with alpha, hsl(), and ideally oklch().
  • Check contrast ratios against WCAG 1.4.3 and 1.4.11.

The Core Concept: Colours Are Values, Images Are Not

The CSS animation engine interpolates any value it can describe numerically. A colour is three channels plus alpha, so any property that takes a single colour transitions smoothly: color, background-color, border-color, outline-color, fill, stroke, the colour in a box-shadow. A gradient, by contrast, is an image, and background-image is discrete — it flips rather than blends.

Two further facts shape everything in this guide. First, colour interpolation happens in a colour space, and the space matters: blending blue to yellow in sRGB passes through grey, while OKLab stays saturated. Current CSS interpolates colour transitions in OKLab by default, and functions such as color-mix() and gradients let you choose the space explicitly. Second, custom properties are untyped unless registered, so a token holding a colour cannot itself interpolate unless it is declared with @property and syntax: "<color>".

What interpolates, and how Single-colour properties transition directly and interpolate in OKLab by default. Gradients and background-image do not interpolate; their inputs must be registered custom properties. Unregistered colour tokens flip; registered ones interpolate. Three kinds of colour value, three behaviours Single colours background-color color, border-color fill, stroke transition directly OKLab midpoints Gradients background-image mask-image flip, never blend animate inputs instead Colour tokens --brand: #4f46e5 unregistered: flips registered <color>: interpolates Most colour work animates the first column; the other two need a deliberate technique.

The four guides in this section each take one part of the system. Hover and Active States With color-mix() derives interaction colours from a single token. Light and Dark Themes With light-dark() pairs theme colours in one declaration. Animating CSS Gradients Smoothly makes the non-interpolable interpolate. Smooth Light/Dark Theme Switch Transitions animates the moment the whole palette changes.

A three-layer colour architecture

Colour systems that stay maintainable tend to have three layers, and each technique in this section lives in one of them.

Palette, roles and states The bottom layer holds raw palette values. The middle layer maps roles such as surface and text to palette pairs with light-dark. The top layer derives component states such as hover and active with color-mix from role tokens. Components reference only the top two layers. Each layer references only the one below Palette --indigo-600: #4f46e5 · --slate-900: #0f172a Theme roles --brand: light-dark(var(--indigo-600), var(--indigo-300)) Component states --brand-hover: color-mix(in oklch, var(--brand), black 12%)

The palette is raw values with no meaning — named hues and steps. Theme roles give meaning and pair light and dark values with light-dark(). Component states derive hover, active, disabled and focus shades from roles with color-mix(). Components reference roles and states, never the palette directly, so no component needs to know which theme is active. With that structure, a rebrand edits the palette, a theme tweak edits one role, and a new component variant sets one role — and every derived state follows.

The layering also answers a question that otherwise causes endless debate: where does a new colour go? A colour with no meaning yet is a palette entry. A colour that means something across the product — "the colour of destructive actions" — is a role. A colour that exists only as a variation of another in a particular interaction — "the danger button while pressed" — is a derived state, and should be computed rather than stored. Teams that apply this test consistently find their token count stays small even as the product grows, because most apparent new colours turn out to be derivations of existing roles.

It also keeps animation predictable. Transitions always run between two computed colours, so whether a hover transition starts from a palette value, a role or a mix makes no difference to the browser. What changes is how many places must be edited to adjust it — ideally exactly one.


Syntax and Parameters

FeatureSyntaxNotes
color-mix()color-mix(in <space>, <color> <pct>?, <color> <pct>?)Spaces include srgb, oklab, oklch, hsl; percentages default to 50%
light-dark()light-dark(<light-color>, <dark-color>)Chosen by the element's used color-scheme
color-schemenormal | light | dark | light darkInherited; also themes form controls and scrollbars
Gradient interpolationlinear-gradient(<dir> in <space> <hue-method>?, …)e.g. in oklch longer hue
@property for colourssyntax: "<color>"Makes a token interpolable; freezes light-dark() at declaration
oklch()oklch(L C H / A)Perceptual lightness, chroma, hue
forced-colors@media (forced-colors: active)System palette overrides author colours
prefers-contrast@media (prefers-contrast: more)Stronger tokens for users who need them

Step-by-Step Implementation: A Themed, Stateful Component Library

Step 1: palette values

:root {
  --indigo-300: #a5b4fc;
  --indigo-600: #4f46e5;
  --slate-50: #f8fafc;
  --slate-900: #0f172a;
  --slate-200: #e2e8f0;
  --slate-700: #334155;
}

Step 2: theme roles with light-dark()

:root {
  color-scheme: light dark;
  --surface: light-dark(var(--slate-50), var(--slate-900));
  --text: light-dark(var(--slate-900), var(--slate-200));
  --border: light-dark(var(--slate-200), var(--slate-700));
  --brand: light-dark(var(--indigo-600), var(--indigo-300));
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

Step 3: derived states per component

.btn {
  --btn-bg: var(--brand);
  --btn-bg-hover: color-mix(in oklch, var(--btn-bg), var(--text) 12%);
  --btn-fg: var(--surface);
  background-color: var(--btn-bg);
  color: var(--btn-fg);
  transition: background-color 140ms ease;
}
.btn:hover { background-color: var(--btn-bg-hover); }

Mixing toward var(--text) instead of plain black makes the hover darken in light mode and lighten in dark mode automatically, because the text role flips with the theme.

Step 4: stronger tokens on request

@media (prefers-contrast: more) {
  :root {
    --border: light-dark(var(--slate-700), var(--slate-200));
  }
}

Step 5: a safety net for forced colours

@media (forced-colors: active) {
  .btn { border: 1px solid ButtonText; }
}

In forced-colours mode the background colours are replaced, so a visible border keeps the button's shape recognisable.


Annotated Production Example: A Status Badge Set

Status badges — success, warning, error, info — are a small system of their own: each needs a background, text and border that work in both themes and meet contrast. With the three-layer approach, each variant sets a single hue.

Live demoStatus badges derived from one hue each
Four variants set only --hue; background, text and border are mixed in OKLCH and follow the frame's light or dark scheme.
.badge {
  --hue: 250;                                            /* the only variant input */
  --badge-base: oklch(55% 0.16 var(--hue));
  --badge-bg: light-dark(
    color-mix(in oklch, var(--badge-base) 14%, white),
    color-mix(in oklch, var(--badge-base) 30%, black)
  );
  --badge-fg: light-dark(
    color-mix(in oklch, var(--badge-base), black 35%),
    color-mix(in oklch, var(--badge-base), white 55%)
  );

  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.2rem 0.6rem;
  border: 1px solid color-mix(in oklch, var(--badge-fg) 35%, transparent);
  border-radius: 999px;
  background: var(--badge-bg);
  color: var(--badge-fg);
  font-size: 0.85rem;
  transition: background-color 200ms ease, color 200ms ease;
}

.badge--success { --hue: 150; }
.badge--warning { --hue: 75; }
.badge--error   { --hue: 25; }
.badge--info    { --hue: 250; }

/* A non-colour cue: every badge carries an icon or text label, so the
   status is readable in forced-colours mode and by colour-blind users. */
.badge::before { content: "●"; font-size: 0.6em; }

Each variant is one number, and each derived colour is computed in OKLCH, so the four badges have matching lightness and saturation — a success badge is not accidentally brighter than an error badge. The light-dark() wrappers produce a pale tint with dark text in light mode and a deep tint with light text in dark mode. The badge text itself carries the status word, which satisfies WCAG 1.4.1 Use of Color: colour reinforces the status but never carries it alone.


Choosing a Colour Space for UI Work

Colour spaces used to be a specialist topic. They now matter to every front-end developer, because color-mix(), gradients and colour transitions all interpolate in a space, and the choice changes what users see.

sRGB is the space of hex codes and rgb(). It is device-oriented: equal numeric steps do not look equal. Mixing or interpolating saturated colours through sRGB tends to produce dull, greyish midpoints, and darkening by a fixed amount affects yellows far more than blues. It remains fine for mixing with transparency, where only alpha changes.

HSL looks friendly — hue, saturation, lightness — but its lightness is not perceptual. A yellow and a blue at the same HSL lightness look nothing alike in brightness, so a palette built by varying HSL hue at fixed lightness has wildly uneven contrast.

OKLab and OKLCH are designed so equal steps look equal. OKLCH expresses the same space as lightness, chroma and hue, which makes it the most practical for design tokens: keep lightness fixed and vary hue to get a family of colours with matching visual weight, as the badge example does. For mixing and interpolation, OKLab and OKLCH both avoid muddy midpoints; OKLCH's hue channel additionally allows choosing the direction round the colour wheel.

The recommendation for new work is simple: author palette values in oklch(), mix in oklch, and write gradients in oklch. Existing hex palettes can stay as they are, since the interpolation space is independent of how the endpoint colours are written.

One caution: OKLCH can describe colours outside the display's gamut, particularly at high chroma. Browsers map such colours back into gamut, which can shift their appearance slightly. Keep chroma moderate for UI colours — roughly 0.1 to 0.2 — and check results on the devices you support.

State Colours in Dark Mode

Interaction states are where naïve dark themes most often fail. In a light theme, darkening a button on hover makes it more prominent against a pale page. In a dark theme, the same darkening makes the button less prominent, sinking it into the dark background. The state still changes, but its meaning inverts.

The fix is to derive states relative to the theme rather than to black or white. Mixing toward the text role, as in the step-by-step example, darkens in light mode and lightens in dark mode, because the text colour flips with the theme. Elevation works the same way: in dark themes, raised surfaces conventionally get lighter rather than casting darker shadows, so a hovered card might mix toward white a little instead of deepening its shadow. Expressing these as derived tokens means the component CSS stays identical across themes while its behaviour adapts.

Focus rings deserve special care. A ring colour chosen for contrast against white may disappear against a dark surface. A two-tone ring — a light outline with a dark box-shadow halo, or the reverse — stays visible on any background, which is the approach recommended in Creating Accessible Focus Indicators.

Integration With Adjacent CSS

Style queries. A section can publish a theme token that descendants react to with a container style query — useful for "inverted" bands on a page. Combined with color-scheme on the section, light-dark() tokens inside it resolve to the opposite theme automatically.

Cascade layers. Palette and role tokens belong in an early tokens layer, derived states alongside components, and forced-colours or high-contrast overrides in a late layer so they reliably win.

View transitions. A theme switch animated with a view transition animates snapshots rather than individual colours, which is the cheapest way to make a whole-page palette change feel smooth.

SVG and currentColor. Inline icons and diagrams that use currentColor inherit text colour and therefore follow the theme and any colour transition on their parent, with no extra rules. Diagrams that need a second colour can read a role token through fill: var(--brand) in a style attribute or stylesheet, which keeps illustrations on the same palette as the interface around them.


Performance and Accessibility Notes

Colour changes repaint, not relayout. Transitioning background-color or color costs a repaint per frame for the affected elements, which is cheap for individual components. Avoid universal colour transitions, which turn every hover and every theme switch into hundreds of simultaneous repaints.

Registered properties repaint too. Gradient and mix-amount animations that rebuild an image each frame are more expensive than plain colour transitions. Keep them short and on small elements.

Contrast must hold in every state and theme. Derived shades are computed, so verify them once per brand colour and theme. Disabled states are exempt from contrast requirements but should remain legible.

Colour is never the only signal. Status, validity and selection must also be conveyed by text, icons or shape, both for colour-blind users and for forced-colours mode, where author colours disappear.

Theme switches honour reduced motion. Sweeping reveals become short fades; crossfades may stay if they are brief. The inline no-flash script matters for light-sensitive users.


DevTools Debugging Workflow

  1. Inspect computed colours. The Computed pane shows the resolved value of color-mix() and light-dark() expressions, which confirms what a derived token actually produced.
  2. Emulate schemes. Chromium's Rendering drawer and Firefox's inspector can emulate prefers-color-scheme, prefers-contrast and forced-colors, so each theme and mode can be checked without changing system settings.
  3. Check contrast in place. Chromium's colour picker shows the contrast ratio against the background beneath the element, with AA and AAA markers; use it on every derived state.
  4. Watch for flips. If a colour transition jumps instead of blending, check whether the animated value is an unregistered custom property or a gradient.
  5. Verify nested schemes. Select an element inside a color-scheme: dark region and confirm light-dark() tokens resolve to their dark values there.

Browser Compatibility

FeatureChrome / EdgeFirefoxSafari
color-mix()111+113+16.2+
light-dark()Supported in current versionsSupported in current versionsSupported in current versions
@property85+128+16.4+
View transitions (theme reveal)111+144+18+
prefers-contrast96+101+14.1+
forced-colors89+ / 79+89+16+

Plain colour transitions are universal. Declare hex fallbacks before color-mix() and light-dark() declarations for older engines.


Common Pitfalls

PitfallCauseResolution
Gradient snaps on hoverbackground-image is discreteAnimate registered colour or position inputs
Hover shade wrong on variantsDerived tokens declared on :rootDeclare derived tokens on the component
light-dark() always lightNo color-scheme including darkcolor-scheme: light dark on the root
Nested dark region ignoredToken registered as <color>Keep light-dark() tokens unregistered
Theme switch stuttersUniversal transition on coloursScope to large surfaces or use a view transition

FAQ

Which colour properties can be transitioned? Any property whose value is a single colour: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, accent-color and the colour inside box-shadow. Gradients and background-image cannot, unless the gradient is built from registered custom properties.

What colour space do colour transitions use? Colour transitions and animations interpolate in OKLab by default in current CSS, which keeps midpoints between saturated colours vivid rather than grey. Older implementations interpolated in sRGB, which is why some colour transitions used to look muddy halfway through.

Are colour transitions expensive? A colour change requires a repaint of the element, but not layout. Transitioning a few elements' colours is cheap. Transitioning hundreds at once, as a universal theme transition does, can cost frames, so scope colour transitions to the elements that need them.

How do colour transitions interact with forced-colors mode? In forced-colors mode the browser replaces author colours with a small system palette, so colour transitions largely disappear. That is expected. Make sure no state is communicated by colour alone, so the interface still works when colours are forced.

Should the theme follow the operating system or a toggle? Both. Default to the system preference with color-scheme: light dark, and offer a toggle that overrides it and persists the choice. Apply the stored choice before first paint to avoid a flash of the wrong theme.

Related articles

More pages in the same section.