Motion

Every animation in the kit answers a question the user just asked. Nothing loops, nothing bounces, and nothing moves an element that was already on screen and unchanged.

One file, four kinds of movement

Timing lives in lib/motion.ts the way colour and radius live in lib/styles.ts: components reach for a token rather than spelling out their own duration, so the whole kit can be retuned from one place.

KindTokenWhat it says
ArrivalenterFade, enterRise, enterPop, overlayInThis was not here a moment ago — and, for a floating layer, which edge it came from.
QuantitygrowIn, growTransition, strokeTransitionThis box is a number. Watch it get there.
Responsepressable, interactiveYour click landed, before the app has decided what it meant.
CountinguseCountUp, useCountUpText, useGrowInA figure arriving at its value rather than appearing at it.

Numbers count, bars grow

Anything whose whole content is a figure counts to it — Stat, Gauge, Donut, every meter and every score ring. Tables do not: forty cells counting at once is noise, and the reader is scanning the column rather than watching any one cell. Fmt takes an animate prop for the cases in between.

Counting a value you already have
tsx
import { useCountUpText } from '@/lib/motion'

function Total({ cents }: { cents: number }) {
  return <span className="tabular-nums">{useCountUpText(cents / 100)}</span>
}

Bars and arcs use a cheaper mechanism. useGrowIn returns its start value for exactly one frame, which turns the CSS transition a component already had into a mount animation — one extra render, rather than one per frame for the length of the animation.

Reduced motion is not a downgrade

Every token here is written with motion-safe / motion-reduce, and every hook returns its final value immediately when the preference is set. Nothing is left half-drawn: a skeleton keeps its tint when the sweep is taken away, a gauge is simply already full.

Check it before you ship
Turn on Reduce motion in your OS and reload. Anything that still moves is a bug, and anything that disappeared instead of settling is a worse one.

Where motion is banned

Fields never move. interactive is worn by inputs, tracks, indicators and menu rows as well as buttons, which is why press feedback lives in a separate pressable token — a text input that shrinks when you click into it is a bug, not a flourish.

Floating layers never scale. The popper measures its own painted width to detect a scaling ancestor, so a layer that scaled itself would be measured mid-animation and positioned against a size it is about to stop being. They fade and travel four pixels, from the side they are anchored to.