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 ideallyoklch(). - 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>".
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.
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
| Feature | Syntax | Notes |
|---|---|---|
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-scheme | normal | light | dark | light dark | Inherited; also themes form controls and scrollbars |
| Gradient interpolation | linear-gradient(<dir> in <space> <hue-method>?, …) | e.g. in oklch longer hue |
@property for colours | syntax: "<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.
.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
- Inspect computed colours. The Computed pane shows the resolved value of
color-mix()andlight-dark()expressions, which confirms what a derived token actually produced. - Emulate schemes. Chromium's Rendering drawer and Firefox's inspector can emulate
prefers-color-scheme,prefers-contrastandforced-colors, so each theme and mode can be checked without changing system settings. - 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.
- Watch for flips. If a colour transition jumps instead of blending, check whether the animated value is an unregistered custom property or a gradient.
- Verify nested schemes. Select an element inside a
color-scheme: darkregion and confirmlight-dark()tokens resolve to their dark values there.
Browser Compatibility
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
color-mix() | 111+ | 113+ | 16.2+ |
light-dark() | Supported in current versions | Supported in current versions | Supported in current versions |
@property | 85+ | 128+ | 16.4+ |
| View transitions (theme reveal) | 111+ | 144+ | 18+ |
prefers-contrast | 96+ | 101+ | 14.1+ |
forced-colors | 89+ / 79+ | 89+ | 16+ |
Plain colour transitions are universal. Declare hex fallbacks before color-mix() and light-dark() declarations for older engines.
Common Pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Gradient snaps on hover | background-image is discrete | Animate registered colour or position inputs |
| Hover shade wrong on variants | Derived tokens declared on :root | Declare derived tokens on the component |
light-dark() always light | No color-scheme including dark | color-scheme: light dark on the root |
| Nested dark region ignored | Token registered as <color> | Keep light-dark() tokens unregistered |
| Theme switch stutters | Universal transition on colours | Scope 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
- Hover and Active States With color-mix() — derived interaction colours.
- Light and Dark Themes With light-dark() — paired theme tokens.
- Animating CSS Gradients Smoothly — animating what does not interpolate.
- Smooth Light/Dark Theme Switch Transitions — the switch itself.
- Hover & Focus State Design — where most colour transitions happen.
- Container Style Query Theming — themes scoped to sections.
Related articles
More pages in the same section.