Modern CSS Design Primitives
A design system written in 2022 hand-rolls things the browser now does natively: a second set of dark-mode variables, a Sass function for tints, a build step to pick text colour. Several of those jobs moved into CSS, and the ones that did are interoperable now, not "coming soon".
This is what changed, what it replaces, and — the part most write-ups skip — where each one stops being enough.
Colour
light-dark() — one token, both themes
Baseline since May 2024. It collapses the two-block token file into one.
:root {
color-scheme: light dark; /* required, or it does nothing */
--surface: light-dark(#ffffff, #0a0a0b);
--text: light-dark(#171717, #fafafa);
}
color-scheme: light dark is not optional. Without it the function silently returns its first argument, which looks like "dark mode is broken on one element" and is very hard to spot. Set it once on :root.
It takes images and gradients too, so a light/dark hero or icon no longer needs a media query.
Where it stops: it resolves at used-value time, so you cannot read the resolved colour back in JS, and you cannot use it to feed a build step. If a token has to be exported to iOS or Android, keep the two values separately and let generate_design_tokens emit the platform files.
color-mix() — derive instead of declare
Baseline widely available since May 2023, and the most under-used of the three.
--primary: #4f46e5;
--primary-hover: color-mix(in oklab, var(--primary), black 12%);
--primary-subtle: color-mix(in oklab, var(--primary), white 88%);
--border: color-mix(in oklab, var(--text), transparent 88%);
Mix in oklab or in oklch rather than in srgb: sRGB mixing darkens and desaturates through the middle, which is why a hand-made hover state so often looks muddy. Percentages normalise — omit one and it becomes 100% − other; if they total under 100% the remainder becomes transparency.
Where it stops: derived hover and subtle states are still unverified colours. A color-mix() result can fall below contrast just as easily as a hand-picked one. Run the pair through audit_accessibility.
contrast-color() — and its real limits
Baseline newly available since April 2026. Pass a colour, get back the one of white or black that contrasts with it more.
.badge { background: var(--brand); color: contrast-color(var(--brand)); }
Read that definition again, because the name oversells it. It returns only white or black — never a tinted or nudged colour — and MDN is explicit that WCAG AA contrast "is not capable of producing clearly readable text in all cases". Its own worked example is royal blue #2277d3, where the function returns black, and black on that blue is not readable at small sizes.
So it is genuinely useful for surfaces that are clearly light or clearly dark, and it is a trap for mid-tones — which is exactly where brand colours live.
The rule: use it for on-colour text over light or dark fills, and verify anything mid-tone. When neither black nor white clears the threshold, fix_contrast is the tool for the job: it holds hue and saturation and moves lightness until the pair passes, which returns a colour that still looks like your brand. contrast-color() cannot do that by design.
oklch() for the ramp itself
Perceptually uniform lightness means a ramp built by stepping L actually looks evenly spaced, which hsl() does not. If you are generating scales rather than picking them, work in OKLCH. generate_color_system does the equivalent and verifies every pair while it goes.
Layout and sizing
- Container style queries (Baseline 2026) — react to a custom property on an ancestor, not just its width. This is how a component finally responds to context (
--density: compact) rather than measuring pixels. field-sizing(Baseline 2026) —field-sizing: contentmakes an input or textarea grow with what is typed, which removes the most common piece of resize JavaScript in any form.- New font-relative units
rcap,rch,rex,ric(Baseline 2026) — root-relative versions of cap height, character width, x-height and ideograph width.rchis the honest unit for a measure:max-width: 65rchsays what you mean, where65chdrifts with the element's own font. shape()(Baseline 2026) — responsive custom shapes forclip-path, in real CSS units rather than an SVG path's coordinate space.text-boxtrims the leading above and below a text block, so a heading's optical box matches its visual one. It is the fix for "why is there always extra space above my h1".
Structure and interaction
@scope— style a subtree without inventing a BEM prefix, and stop the styles leaking. Useful when a design system and a legacy stylesheet share a page.:openand::details-content(Baseline 2026) — style a<details>in its open state, and animate its content. Disclosure UI without JavaScript.- View transitions — cross-document and same-document animated state changes. Treat as motion, not decoration: everything in
animation-craftstill applies, includingprefers-reduced-motion. - CSS anchor positioning — tether a popover, tooltip or menu to its trigger with
anchor-name,position-anchor,position-area,anchor(),anchor-size(), and fall back with@position-try/position-try-fallbackswhen it would overflow. This is the native replacement for a positioning library. Support is uneven at the time of writing — unlike the features above, do not assume it is interoperable; check current support and keep a fallback.
Checklist
-
color-scheme: light darkset on:rootbefore anylight-dark()is used. - Derived states built with
color-mix(in oklab, …), not hand-picked hexes. - Every
contrast-color()surface is clearly light or clearly dark; mid-tones verified withaudit_accessibilityand repaired withfix_contrast. - Ramps generated in a perceptual space, not
hsl(). - Measure expressed in
rch(orchon the element that owns the text), capped at 45–75 characters. - Inputs that should grow use
field-sizing: contentinstead of a resize observer. - Anything relying on anchor positioning has a checked fallback.
- Tokens that must reach iOS or Android kept as discrete values, not only as
light-dark()pairs.
Anti-patterns
- Treating
contrast-color()as an accessibility guarantee. It returns black or white and nothing else, and its own documentation says WCAG AA does not guarantee readability. It is a convenience, not a verifier. light-dark()withoutcolor-scheme. Fails silently to the light value; the bug surfaces as one element that "didn't get dark mode".- Mixing in sRGB.
color-mix(in srgb, …)passes through a muddy middle. Useoklab/oklchunless you specifically want the sRGB path. - Replacing a token system with functions. These compose values; they do not name them. A hover state still needs to be a token so it can be audited, exported and changed in one place —
audit_design_systemcounts acolor-mix()result the same as a hex. - Adopting a feature because it is new. Everything above except anchor positioning is interoperable; anchor positioning is not yet, and a tooltip that lands off-screen in one browser is worse than a library.
- Assuming a Baseline date means universal. "Newly available" means the current versions of the core browsers. Users on older devices exist; decide deliberately whether they get the enhancement or the fallback.
See also: design tokens theming, color systems, design engineering, accessibility, animation craft, web trends 2026.