Motion

Easing curves, durations, and prebuilt keyframe animations. All motion must respect prefers-reduced-motion — see the pattern below.

Durations

Write durations as literal values and use tokens for easing curves. Open Props does ship a --duration-* scale, but it lives in open-props/extra/durations.css, which /ui/ui.css does not import — so var(--duration-quick-1) resolves to nothing and the browser drops the whole declaration. The browser reports no error, so this can look like a very fast transition.

Choose the nearest value from those already used in the styles:

When several rules inside one component share a number, name it once as a component-local private prop instead of repeating the literal. The side panel does this with --_duration: its slide and its chevron rotation both read it, so the two cannot drift apart. See Tokens § "Pattern: component-local private props".

Easing — --ease-*

Show code .button { transition: background .12s var(--ease-2); } .modal { transition: transform .3s var(--ease-out-3); } ui-theme-toggle .sun { transition: transform .5s var(--ease-elastic-3); }

Animation keyframes — --animation-*

Prebuilt keyframes: --animation-fade-in, --animation-fade-out, --animation-slide-{in,out}-{up,down,left,right}, --animation-scale-up, --animation-spin, --animation-ping, --animation-blink, --animation-bounce, shake-x, and more.

Show code .toast { animation: var(--animation-slide-in-up) forwards; } .loader { animation: var(--animation-spin); }

Reduced motion

Wrap transitions and keyframe animations in @media (prefers-reduced-motion: no-preference) so users who opt out get static UI. Final-state rules apply regardless.

Write that query out in full — @media (--motionOK) never matches here. Open Props names the same query as a @custom-media declaration in props.media.css, and no browser resolves @custom-media by itself: it takes a build step, and this project serves its CSS as written. The browser drops the declaration, then drops every rule inside @media (--motionOK) — so the transition never runs, regardless of the preference, and the browser reports no error. Put the Open Props name in a comment beside the standard query, the way the named breakpoints are written (Breakpoints, section "Open Props and @custom-media").

Show code @media (prefers-reduced-motion: no-preference) { /* --motionOK */ .card { transition: transform .2s var(--ease-2); } .card:hover { transform: translateY(-2px); } }

Project status animations

Five named animations built on Open Props tokens, shipped in /ui/ui.css. Live demos: Components · Feedback · Status messages.

Use play_once(el, class) from lib/animation.mjs for an animation that runs once. Adding an existing class does not restart it. Removing and re-adding the class within one task also fails, because the browser computes styles only after the task finishes.

.status-success
Green pulse — background flash + subtle scale. Falls back to 1 s flash under reduce-motion.
.status-error
Red horizontal shake (shake-x keyframes). Disabled under reduce-motion.
.reveal-flash
Blue --info tint + subtle scale, played by reveal(el), which scrolls the element into view first. This flash identifies the selected element without changing its own colors. Under reduce-motion the tint lasts 1.2 s and the scale bounce is disabled. Keeping the tint helps users identify the element after scrolling. The JavaScript helper also checks this preference when choosing scroll behavior. This avoids relying on every caller to override the default scroll-behavior value of auto.
.attention-pulse
--brand ring expanding out of the control, played by play_once(el, class, {pseudo: '::after'}). This draws attention to a control that received focus after a pointer gesture, when :focus-visible does not match and no ring is painted. It runs on ::after rather than the element, so it can run even when the host declares animation or all of its own; the pseudo argument lets play_once size its fallback timer. Under reduce-motion the ring remains visible for 1.2 s without growing. See Status messages.
.spin
Continuous rotation for loading indicators — see Loading & spinners. Use spin(icon, active) from lib/ui.mjs to toggle. This animation continues under reduced motion to indicate activity. If an application overrides --animation-spin with a faster rate, it restores the slower rate under @media (prefers-reduced-motion: reduce).

Pitfalls

See also