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.
| Kind | Token | What it says |
|---|---|---|
| Arrival | enterFade, enterRise, enterPop, overlayIn | This was not here a moment ago — and, for a floating layer, which edge it came from. |
| Quantity | growIn, growTransition, strokeTransition | This box is a number. Watch it get there. |
| Response | pressable, interactive | Your click landed, before the app has decided what it meant. |
| Counting | useCountUp, useCountUpText, useGrowIn | A 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.
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.
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.