Motion & Reaction
The animation vocabulary that goes with the layout and charting vocabulary. Two references sit behind it: the cursor-repel heading from finder, and the live-custom-property discipline from prop-for-that.
The second is the important one, and it is a rule rather than an effect: JavaScript writes numbers, CSS decides what they look like. A pointer handler's
entire job is to set --live-pointer-x-ratio to a value between 0 and 1. Whether
that becomes a tilt, a glow, a parallax or nothing at all is a stylesheet decision — so motion
can be rethemed or switched off without touching script. Adopted as a pattern only: this
library stays on vanilla CSS, hex colour and data-theme, not oklch, light-dark() or a webfont.
What this is. A page title where every letter is a separate element on a spring. Bring the pointer near one and it is pushed away; take the pointer off and it drifts back to where it belongs. That is the whole effect — there is nothing to click, no state, and no outcome. It exists to make a masthead feel like a physical surface rather than a printed one, and it is here because the same three numbers behind it (how far the pointer reaches, how hard it pushes, how the thing returns) are the numbers behind every other bit of motion on this page.
How it is built. Each glyph is a span carrying its own spring. The pointer
pushes characters away with a linear falloff inside an 80px radius — the dashed circle that
follows your cursor below is that radius, drawn; anything inside it is being pushed, anything
outside it is not. When the pointer leaves, the glyphs settle back under damping rather than
snapping. One requestAnimationFrame loop walks a flat array of glyphs — no
per-character listeners and no CSS transitions, because a transition would fight the spring for
control of transform. Under prefers-reduced-motion the loop never
starts and the text stays ordinary, selectable type.
What the two cards below are for. They are the same effect with one group of numbers changed, side by side, so the parameters mean something you can feel instead of read. Sweep your pointer across all three at the same speed and compare: radius and strength decide how far the pointer's influence reaches and how hard it shoves — that is the outbound half; stiffness and damping decide how the glyphs travel home once it leaves — the return half. Tuning those two halves separately is the entire craft of spring motion.
Two tiers of naming. --live-* properties change continuously and may be rewritten
many times per second; --const-* properties latch once and are then left alone. The
latch is what turns "animate in the first time this is seen" from a JavaScript state machine into
a single CSS rule. All three demos below share exactly one helper each — the reactions are
different only because the stylesheets are.
The handler writes two ratios. The rotation, the depth and the easing back to flat are all in the stylesheet — swapping tilt for parallax is a CSS edit.
Byte-for-byte the same helper as the tile on the left — only the stylesheet differs, which is the whole argument for the pattern. Here the two ratios place a conic hue wheel and the radial mask that reveals it, so the spectrum rotates under the cursor instead of sliding past.
The four words start invisible and 28px low. An IntersectionObserver with 21
thresholds reports how much of the card is on screen as a number rather than a boolean, and
that number is written to --live-visible; the words fade and rise in proportion
to it, staggered 110ms apart. Once 15% of the card has been seen, --const-has-entered latches to 1 and pins them in place — so scrolling back and
forth does not replay the entrance. That latch is why it looks like nothing happens: by the
time the card is comfortably in view the reveal is already over. Replay re-arms the observer from zero.
Application state publishes a single --live-value-pct. A bar, a dial, a gradient
stop and a text colour all read it. Nothing recalculates in script when the number moves — the
frame budget goes to the compositor instead. Use the controls below to drive it: pause holds the
last value so the four readouts can be compared against each other, and the FPS slider sets how
fast the number is republished.
Four easings and three durations cover everything in this library. The point of naming them is
consistency across a codebase, not expressiveness: an interface where every transition was
individually tuned reads as noise. --ease-out does most of the work —
fast at the start, long settle, which is what makes an entrance feel decided rather than slow.
| Easing | Curve | Use | Preview |
|---|---|---|---|
--ease-out | cubic-bezier(.16, 1, .3, 1) | entrances, reveals | |
--ease-in-out | cubic-bezier(.65, 0, .35, 1) | state swaps | |
--ease-spring | cubic-bezier(.34, 1.56, .64, 1) | button presses | |
--ease-linear | linear | progress, streams |
| Duration | Value | Use | Preview |
|---|---|---|---|
--dur-fast | 120ms | hover, focus, press | |
--dur-base | 240ms | panels, popovers | |
--dur-slow | 480ms | page-level reveals |
prefers-reduced-motion: the repel loop does not
start, and the transitions collapse to zero duration.