Elevation, radius & motion
Three smaller systems share a page because they share a job: giving flat rectangles shape, depth, and behavior. Each is a short scale of tokens, and each moves under a DNA gene.
Radius
Six steps, from square to pill. The named steps map to component classes of increasing size.
| Token | Value | Use for |
|---|---|---|
--helix-ui-radius-none | 0 | Sharp corner, flush edges |
--helix-ui-radius-sm | 4px | Chips, badges, tags |
--helix-ui-radius-md | 8px | Buttons, inputs, selects |
--helix-ui-radius-lg | 12px | Cards, panels |
--helix-ui-radius-xl | 16px | Modals, sheets |
--helix-ui-radius-full | 9999px | Pills, avatars, toggles |
The step tracks the element’s size: a badge at sm and a modal at xl read as the same corner softness because the radius grows with the box. full is a sentinel — 9999px clamps to a semicircle on any height, so a pill stays a pill whether it’s 24px or 40px tall.
On viewports ≤767px the static build eases the larger radii ~10% (md 8→7px, lg 12→11px, xl 16→14px) so corners don’t look chunky on small screens.
Radius moves under DNA
The radius gene multiplies every corner by a factor; the wildtype is standard (1.0).
| Allele | Factor | radius.md becomes |
|---|---|---|
sharp | 0.35 | 2.8px |
subtle | 0.7 | 5.6px |
standard | 1.0 | 8px |
rounded | 1.4 | 11.2px |
extreme | 2.2 | 17.6px |
express(dna) computes base × radius.factor × curveFactor and rewrites --helix-ui-radius-* on the DNA wrapper. A second gene, radiusCurve, bends the ramp — progressive shrinks small radii and grows large ones (chips get sharper, sheets get rounder) rather than scaling them uniformly.
Elevation (shadows)
Three levels. Depth is the only thing they encode — a bigger shadow means “floats higher above the page,” which maps to how transient the surface is.
| Token | Value | Use for |
|---|---|---|
--helix-ui-shadow-sm | 0 1px 2px 0 rgb(0 0 0 / 0.05) | Cards resting on a page |
--helix-ui-shadow-md | 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) | Popovers, dropdowns |
--helix-ui-shadow-lg | 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) | Modals, sheets |
Each level is two stacked shadows — a tight near-shadow plus a softer far one — which reads more like real light than a single blur. The mapping is intentional: the more temporary a surface, the higher it floats. A card (sm) is part of the page; a dropdown (md) is hovering; a modal (lg) has taken over.
Elevation moves under DNA
The surface gene swaps the entire shadow set. Wildtype is elevated, whose values are exactly the three above.
flat— near-invisible hairline shadows (smisnone,mdis a single0 1px 0 0 rgb(0 0 0 / 0.06)). For dense or print-like UI.elevated— the standard soft-drop set shown above.glassy— a top highlight plus a deep, wide shadow (0 1px 0 0 rgb(255 255 255 / 0.08), 0 8px 24px -8px rgb(0 0 0 / 0.18)atmd) for translucent/blurred surfaces.
Curvature (the lamp)
Elevation says how far above the page a surface floats. Curvature says which way it bends — and it is what makes a progress channel read as cut into a card and a radio bead read as sitting on it.
Both come from one light source, declared once in material.css and read by every component that has a shaped surface.
| Token | Default | What it controls |
|---|---|---|
--helix-ui-material-lit | brand 200 | The colour of the light |
--helix-ui-material-shade | brand 900 | The colour of the shadow it casts |
--helix-ui-material-light-angle | 145deg | Where the light comes from — up and to the left |
--helix-ui-material-recess-shade | 24% | Top-lip shading on a surface cut into its ground |
--helix-ui-material-recess-lit | 88% | Bottom-lip highlight on the same |
--helix-ui-material-relief-shade | 28% | Underside shading on a surface sitting on its ground |
--helix-ui-material-relief-lit | 95% | Top-edge highlight on the same |
--helix-ui-material-relief-cast | 20% | The short shadow it drops |
--helix-ui-material-specular | 100% | Strength of the highlight across a raised face |
--helix-ui-material-fill | 82% | How much of a small control’s own body survives |
--helix-ui-material-lift | surface | The surface one step toward the light from the page |
The shade is deliberately the smaller half of each pair. It is a tint of the ramp’s darkest stop, so on a warm surface it puts the accent’s hue in the shadows — and enough of it turns amber to mud. Depth comes from the highlight instead, which can only ever lighten and so cannot muddy anything.
A recess and a relief are the same two stops the other way up. That is the whole notation: under a fixed lamp, the sign of the shading says which way a shape bends, with no extra colour needed to say it. ProgressBar’s channel, SegmentedControl’s track and the grid field of a calendar are recesses; the radio bead, the segmented thumb and an event tile are reliefs.
The 200 and 900 stops are the pair because the brand ramp is generated by lightness and does not invert between themes — one pair describes curvature in light mode and in dark, which no semantic token pair can do.
The lamp moves under DNA
express() writes --helix-ui-material-lit and --helix-ui-material-shade onto every DNA provider, beside the ramp it derived them from, so a genome re-lights every material inside it.
That is deliberate rather than incidental. A custom property’s var()s resolve against the element the property is declared on, so aliasing the pair once at :root would bake in the root theme’s ramp and every surface inside a provider would keep lighting itself from a genome it is not using. If you override --helix-ui-color-brand-200 by hand instead of using a provider, override the lamp alongside it.
Materials
Curvature says which way a surface bends. A material says what it is made of, and this part is Apple’s answer rather than a general one: on iOS a piece of chrome is not a translucent rectangle, it is a blur of whatever is behind it, saturated back up, with a thin tint on top. The blur is what makes it read as glass rather than as a pale sheet; the saturation is what lets a wallpaper’s colour survive the blur.
| Token | Default | Use for |
|---|---|---|
--helix-ui-material-blur-thin | 20px | A bar you should still see through clearly |
--helix-ui-material-blur-regular | 30px | Standard chrome |
--helix-ui-material-blur-thick | 44px | Chrome over busy content |
--helix-ui-material-saturate | 180% | Colour recovery after the blur |
--helix-ui-material-chrome-fill | 72% | How opaque a bar is |
.helix-ui-material-chrome applies the regular thickness. Engines without backdrop-filter get the translucent fill alone, and prefers-reduced-transparency turns the blur off.
These are for chrome — bars, rails, plates. Not for small controls: a blur is a compositor layer, and a page with forty blurred chips pays for forty of them to soften a strip a few pixels tall. Small controls get their glass from translucency and the lamp, which cost nothing. When a whole plate of small things needs to be a material, blur the plate once rather than each thing on it.
.helix-ui-material-vibrant is the label treatment that goes with it: a translucent label takes its lightness from the material under it, which is what iOS vibrancy does. Secondary text only — a primary label that has to be read gets its full colour on iOS too.
Continuous corners
Apple’s corners are superellipses, not circular arcs: the curvature ramps up gradually instead of starting the moment the straight edge ends, which is why an iOS card looks softer than a CSS one at the same border-radius. corner-shape: squircle is applied to the surfaces whose radius is large enough for the difference to show — cards, sheets, dialogs, popovers, buttons, inputs, the segmented control. Engines without it keep the arc.
It is a list rather than a sweep on purpose: corner-shape on an element that clips its children changes what gets clipped, so it is a decision per surface.
Colours the lamp can paint
--helix-ui-material-lit and --helix-ui-material-shade are clamped into sRGB, while the rest of the ramp is not.
The ramp stays wide because a fill is painted opaque and a display that can show the colour should. The lamp is the one pair that is always used with alpha — every material composes it through color-mix(…, transparent) — and a translucent shadow colour outside sRGB wraps rather than clamps on current Chromium: oklch(87% 0.1125 234) at 95% alpha paints lime instead of pale blue. Clamping reduces chroma at fixed lightness and hue, which is what a gamut mapper does anyway, so the opaque appearance is unchanged.
Lift
Glass scatters, so a pane reads lighter than what is behind it. That cannot be said with one token, because the surface scale inverts between themes: in light mode bg-surface-default (100%) sits above bg-surface-subtle (96%), and in dark mode it sits below it (18% against 28%) while bg-surface-muted (38%) is the one above.
Reach for bg-surface-default and a pane lifts off the page in light mode and sinks into it in dark. --helix-ui-material-lift is a name for the relationship rather than for a step on the scale, and it is what panes, plates and shelves should take their fill from.
express() emits it on every DNA provider next to the surface tokens it is derived from, for the same reason the lamp is emitted there: an alias declared once at :root resolves against the root’s tokens and never moves again.
Reduced transparency
--helix-ui-material-fill is the one knob a control’s body reads, so prefers-reduced-transparency: reduce answers for all of them at once by setting it to 100%. The shading stays — none of it is transparency.
Motion
Motion is two token groups: duration (how long) and easing (the velocity curve).
Duration
| Token | Value | Use for |
|---|---|---|
--helix-ui-motion-duration-instant | 0ms | Suppress a transition entirely |
--helix-ui-motion-duration-fast | 120ms | Hover, press, focus ring, tooltip |
--helix-ui-motion-duration-normal | 180ms | Most state changes, popovers, toasts |
--helix-ui-motion-duration-slow | 240ms | Sheets, dialogs, drawers |
--helix-ui-motion-duration-slower | 320ms | Collapsing sections, page transitions |
The rule of thumb: duration scales with travel. A focus ring barely moves, so fast; a drawer crosses the viewport, so slow. Anything past slower starts feeling broken rather than deliberate.
Easing
| Token | Value | Use for |
|---|---|---|
--helix-ui-motion-easing-linear | linear | Spinners, indeterminate progress |
--helix-ui-motion-easing-standard | cubic-bezier(0.2, 0, 0, 1) | Default UI ease |
--helix-ui-motion-easing-emphasized | cubic-bezier(0.32, 0.72, 0, 1) | Sheets, toasts |
--helix-ui-motion-easing-decelerate | cubic-bezier(0, 0, 0.2, 1) | Elements entering the viewport |
--helix-ui-motion-easing-accelerate | cubic-bezier(0.4, 0, 1, 1) | Elements leaving the viewport |
--helix-ui-motion-easing-spring | cubic-bezier(0.16, 1, 0.3, 1) | Entrance scale-ups, hover lifts |
The pairing that matters most: things entering use decelerate (fast in, gentle stop — they arrive and settle); things leaving use accelerate (slow start, quick exit — they’re gone before you track them). standard handles the in-place changes that are neither.
Shared motion utilities
@helix-ui/core ships a motion stylesheet (motion.css) of keyframes and classes so you rarely write @keyframes yourself. Each references the tokens above, so overriding a token retunes the whole system.
<div className="helix-ui-anim-slide-in-up">Appears from below</div><div className="helix-ui-anim-stagger"> {/* children with .helix-ui-anim-* enter in a 20ms → 40ms cascade */}</div><button className="helix-ui-press">Scales to 0.97 on :active</button>Motion moves under DNA
The motion gene rescales durations. Wildtype is standard; subtle compresses everything (fast 90ms / normal 150ms / slow 220ms), lively stretches it (160 / 280 / 420ms). The gene retunes --helix-ui-motion-duration-* on the wrapper, and a separate easing gene can swap the curves (e.g. a bouncier spring set) for a different feel without touching a component.
Reduced motion
Honored once, at the bottom of motion.css. When the OS reports prefers-reduced-motion: reduce, every helix-ui transition and animation collapses:
@media (prefers-reduced-motion: reduce) { [class^='helix-ui-'], [class*=' helix-ui-'], [class^='helix-ui-']::before, [class^='helix-ui-']::after, [class*=' helix-ui-']::before, [class*=' helix-ui-']::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}Durations go to 0.01ms, not 0 — that keeps them effectively instant while still firing the transitionend / animationend events that component logic listens for, so nothing hangs waiting on an animation that got skipped. Because one rule covers every helix-ui element, individual components don’t each guard for reduced motion; they inherit it. The instant (0ms) token is the manual equivalent when you need to suppress one transition in code.
Your animations are yours
The selector is scoped to helix-ui elements on purpose. A bare * with !important would reach out of the library and switch off animations in the app that imported it — including ones it handles deliberately — and !important would leave no way to opt out. Importing our stylesheet should get you components, not a policy for your whole page.
If you do want the global behavior, it’s yours to add, and it’s one block. apps/site in this repo does exactly that:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }}In practice
/* CSS — a card that lifts on hover */.tile { border-radius: var(--helix-ui-radius-lg); /* 12px */ box-shadow: var(--helix-ui-shadow-sm); transition: box-shadow var(--helix-ui-motion-duration-fast) var(--helix-ui-motion-easing-standard), transform var(--helix-ui-motion-duration-fast) var(--helix-ui-motion-easing-standard);}.tile:hover { box-shadow: var(--helix-ui-shadow-md); transform: translateY(-2px);}// React — components already resolve these internally<Card>Rests at radius-lg + shadow-sm</Card><Dialog>Enters at duration-slow with the decelerate ease</Dialog>Every value here is a token, so a DNA swap — sharper corners, flatter shadows, snappier motion — re-derives all of it at once. The DTCG source and build outputs for shape, elevation, and motion tokens are in the Tokens reference.