Skip to main content

Context

The design system had motion tokens for a few steps (--duration-* and --ease-* in @stackra/tokens), but no shared entrance, and each component kept its library’s animation default. Some of those defaults are off. HeroUI Pro turns the KPI sparkline’s animation off. HeroUI Native Pro’s chart series take an animate config that is unset by default, so a series jumps when its data changes. HeroUI Pro’s and HeroUI Native Pro’s NumberValue swap the number without animating, although our docs said it animated. Lists had no entrance. An app that wants motion then sets it per screen, with its own timings, and forgets reduced motion in some of those places. Liquid Glass adds limits. Animating the opacity of a native GlassView, or of a view that contains one, breaks the material. Animating a blur radius on the web repaints the whole backdrop on each frame.

Decision

  1. Motion tokens. Theme.motion gains two steps. reveal (400ms, cubic-bezier(0.22, 1, 0.36, 1)) is an entrance: a row arriving, a chart drawing in, a number rolling. stagger (40ms) is the delay between items that reveal in a row; only its duration is read. The web stylesheet renders them as --duration-reveal, --ease-reveal, --duration-stagger and --ease-stagger, and sets every duration to 0ms under prefers-reduced-motion: reduce, as for the other steps.
  2. motionTiming. @stackra/tokens exports motionTiming(theme, name), which reads a step as milliseconds and a cubic bezier for animation libraries in JavaScript (Motion, Reanimated, Recharts, NumberFlow). It throws a RangeError for a missing step or a timing it cannot read. A steps() easing returns no bezier, and the caller uses its own curve. Components read timings through it, never as literals.
  3. Reveal. @stackra/ui ships Reveal, an entrance that fades its child in and raises it 8 pixels on the reveal step, delayed by index × stagger. On the web it is a Motion motion.div. On native it is a Reanimated Animated.View with a FadeInDown entering animation. isAnimated={false} renders the child in place. Under reduced motion, the web version starts at rest (Motion’s useReducedMotion), and the native one follows the system setting (ReduceMotion.System).
  4. Animation on by default. Each of these animates unless its caller turns it off. Each respects reduced motion.
    • List, on both platforms, reveals its rows as one block inside its surface (isAnimated, on by default). A per-row stagger is left out: rows arrive in pages and recycle, so one entrance for the block keeps a reused row from replaying.
    • The web Kpi.Chart draws its sparkline in on the reveal step. Recharts’ isAnimationActive: "auto" keeps it still under reduced motion and on the server. A caller’s areaProps win.
    • Native charts (AreaChart, BarChart, LineChart, PieChart, ComposedChart) give their series a timing animate on the reveal step, so a series moves to new data instead of jumping. Reanimated’s timing follows the system’s reduce-motion setting. A caller’s animate wins, and the root’s animation="disable-all" still stills every series. Grouped bars (BarGroupItem) keep HeroUI’s default, because BarGroup reads its items’ props without rendering them.
    • The web NumberValue rolls its number with NumberFlow on the reveal step (isAnimated, on by default). NumberFlow stays still under reduced motion. A render-prop child gets HeroUI Pro’s still number, and so does anything NumberFlow cannot draw: the scientific and engineering notations, and a locale whose digits are not Latin, such as ar-SA and ar-EG with their Arabic-Indic digits. NumberFlow reads each digit as a Latin one.
  5. Unchanged, because they already comply. Web charts keep HeroUI Pro’s Recharts defaults: they animate, with isAnimationActive: "auto". Progress (bar, circle, meter) and toasts animate on both platforms and respect reduced motion. They keep their libraries’ timings. LineChart.AnimatedLine, the native draw-on line, is HeroUI Native Pro’s own.
  6. Unchanged, because the library has no animation. The native NumberValue and TrendChip on both platforms draw still. HeroUI Native Pro offers no animation to turn on, and Stackra does not add one.
  7. Glass. Motion never animates the opacity of a native GlassView or of any view that contains one, and never animates a blur radius on the web. An animation moves the transform or the opacity of the element inside the material. List therefore reveals its rows inside its Surface or ListGroup, not around it. @stackra/ui-pro never imports HeroUI Pro’s glass theme, and its surfaces stay opaque. Refraction is deferred.

Consequences

  • A screen gets motion from its components, with one set of timings, and reduced motion holds without per-screen code.
  • Changing the reveal step in the theme retimes every entrance, chart and number together.
  • @stackra/ui takes Motion as a peer of its ./react entry, optional like the entry’s other UI libraries, so a web app that imports @stackra/ui/react installs it, and a native app does not.
  • An end-to-end accessibility scan can run while content is still fading in, and read a colour mid-fade. A scan therefore emulates reduced motion, which puts every entrance at rest before it is checked.

Enforcement

  • @stackra/tokens tests: motionTiming and the reduced-motion block of the web stylesheet.
  • @stackra/ui tests: Reveal on both platforms and List’s reveal.
  • @stackra/ui-pro tests: Kpi.Chart, the native chart series and NumberValue.
  • The playground’s end-to-end axe scans emulate reduced motion (apps/playground/e2e/__tests__/playground.e2e.test.ts).
  • planned — an oxlint rule that refuses an animated opacity on a native GlassView or its ancestors (decision 7); until it lands, design review checks it.

Supersedes and sources

Supersedes: none. Sources: STA-430 and the owner’s glass decision of 2026-10-24, restated in this ADR’s own words.