Motion
Two curves, a handful of beats, and when to hold still.
Two curve families, and no third
Page interiors move on one editorial curve: cubic-bezier(0.16, 1, 0.3, 1), exported as EASE_EXPO_OUT from lib/ui/motion-tokens.ts. Before PRO-644 that exact array was re-declared locally in 26 files, which is how a single retune becomes 26 edits and a slow drift.
Shell chrome keeps its own contract: 480ms on cubic-bezier(0.22, 1, 0.36, 1), plus SIDEBAR_FLUID_EASE for content beats. Do not mix the two families on one element. The shell curve is tuned for a large slab resizing; the editorial curve is tuned for text arriving.
Everything on this page imports those constants rather than quoting them, so if the numbers above look wrong, the module changed and this sentence is what is stale.
Fast out of the gate, long settle. Reads as arriving rather than as sliding.
A touch less abrupt at the start, which suits type resolving rather than a panel arriving.
One orchestration per mount
A page entrance is a single cascade down the page, not each component deciding for itself. The beats live in lib/ui/dashboard-entrance-motion.ts(and its team and public siblings) as named delays, so "after the title, before the tiles" is a value you import rather than a number you guess.
Async values are a separate problem and get a separate answer: DataReveal crossfades a value in when it lands, instead of swapping a skeleton row for a content row. Swapping rows makes the page jump; crossfading does not.
0.05s0.2s0.15s0.28s0.38s0.42sDelays are read from DASHBOARD_BEATS, so this demo re-times itself if the module is retuned.
Micro-interactions
Colour feedback is transition-colors duration-200. Transform feedback is motion-safe:transition-transform duration-200, rising to 300ms on large surfaces. Nothing interactive is allowed to feel dead, and nothing is allowed to feel busy.
transition-all is banned. PRO-644 took it from 31 files to zero. It animates properties you did not intend, including ones that force layout, and the cost is invisible until a list is long.
Every variant carries the same reaction, which is what makes the app feel like one thing. Hover and press these.
How the mark is allowed to move
The logo has its own motion rules, and they are narrower than the rest of the system: one brief unfurl, a settling rotation, or a low-amplitude breath. No continuous spin. No petals orbiting independently.
And one rule nobody could guess from the artwork: nothing in the mark may scale above 1. The centre clearance is a static cut in the mask, and each crease's inner tip is tucked just inside it, which is the only reason those tips are invisible at rest. Scale past 1 and the tips travel out past the clearance into the petal bodies, cutting five specks of background around the centre. A settle must therefore arrive at 1 from below.
This was proved by holding two frames at scale 1.06 and 1.00 with everything else identical. Specks at 1.06, clean at 1.00. It is why the loader breathes instead of spinning, and why the petal ripple dips to 0.955 rather than lifting to 1.05.
A per-petal wave may start at any petal, which is how the flower above knows which one you pressed. All five petals are exact rotations of one shape, so re-aiming the stagger produces the identical set of neighbour scale differentials on a different pair: a symmetry of an already-verified animation rather than a new one. The amplitude, the stagger and the curve are not covered by that, and the press does swap the ripple's springy curve for a plain expo-out, because a control point above 1 lets the return segment cross scale 1.
Light that travels around the mark goes behind it, not on top. A petal's path runs under its neighbours, so a wash painted above the artwork also paints a slice of the next petal and its edge shows there as a pale arc. One lit petal can live with that; a sweep lighting three cannot. Behind an opaque mark only the spill past the silhouette shows, so the flower's own rim lights up, and a dipping petal then lets more of its glow out.
Petals bloom clockwise out of the centre. Each crease appears to draw itself along its petal's spine, because the crease is a static cut the petal grows through.
A wave travelling clockwise around the five petals. It DIPS rather than lifting, for the no-overshoot reason above.
The same wave, re-aimed. This one starts at the third petal rather than at twelve o'clock, which is what makes a press on the flower above say which petal you hit. The two light layers that go with it live on the navigation itself, behind the artwork.
The only looping brand motion, and it loops because a wait needs a signal. It presses the home button and opens the petals, over and over.
Reduced motion degrades to instant, never to absent
Every animation is gated, by one of three mechanisms: useReducedMotion for JS-driven work, motion-safe: for utility transitions, or a prefers-reduced-motion media query in globals.css for keyframes. On top of that, MotionConfig reducedMotion="user" wraps every LazyMotion root as a safety net.
The rule is that the interface arrives complete and instant, never hidden. An entrance that starts at opacity: 0 and is then disabled leaves a blank page, which is worse than the animation ever was.
Brand keyframes live insidethe media query rather than being switched off by a hook, so a reduced-motion visitor gets the still mark correctly at first paint, before any JavaScript runs. Two exemptions are deliberate: progress spinners stay spinning, because a frozen spinner reads as "stuck", and colour-only fades are left alone, because the preference is about movement.
prefers-reduced-motion is no-preference. Demos animate. Turn the preference on to see them arrive instantly instead.
Read live from the same hook the product uses. Change it in your OS settings and this updates without a reload.