Motion
One helper animates anything: an entrance, hover and press states, and a flourish when a value changes. Animations are plain data, the same in both apps, and reduced motion always wins.
Playground
one button · every prop of the helper
Clicked 0 times. Clicking changes on, which plays the change animation.
// React
<Animate
enter="pop"
hover="lift"
press="squish"
change={{ on: clicks, animation: "pulse" }}
>
<Button>Animate me</Button>
</Animate>
<!-- Svelte -->
<Button {...animate({
enter: "pop",
hover: "lift",
press: "squish",
change: { on: clicks, animation: "pulse" }
})}>Animate me</Button>Change a control and the button remounts, so the entrance replays with the new props. Hover and press it to try the gestures; click it to change on and play the change animation. The code below is exactly what the controls describe.
Timing “preset” keeps each preset’s own timing; tween or spring replaces only the timing, keeping the preset’s keyframes.
Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}Enter
fade · rise · pop · grow — once per element
faderisepopgrowWrap anything. <Animate enter="rise"> attaches to its one child, like asChild; or call useAnimate and put its ref and props on an element. In Svelte, spread animate({ enter: 'rise' }) on the element.
No flash. The element renders hidden only when scripts run and motion is allowed, and the entrance reveals it in the same frame it starts.
Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}Stagger
targets: animate descendants in turn
- Invite your team
- Connect a repository
- Set a budget
- Ship
Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}On scroll
trigger: "visible", the default
First — enters when scrolled into viewSecond — enters when scrolled into viewThird — enters when scrolled into viewSourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}Hover and press
lift · grow · squish · custom
hover: lifthover: custom springStates, not animations. Hover and press move the element to a state and back to rest; pressing wins while held. Hover ignores touch, and press works from the keyboard on anything focusable.
Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}Change
pulse · shake · bump — when a value changes
Notifications
3Saved 0 times
Attention, not decoration. A change animation plays when change.on changes and ends where it began. It never plays on first render, and a shake does not replace the error message: it points at it.
Sourcelib/motion/doc.ts · lib/motion/specs.ts · lib/motion/run.ts · lib/motion/react.tsx · lib/motion/motion.css
lib/motion/doc.ts
/**
* motion — animation as data, run the same way in both apps.
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § CONTRACT — the oracle. Names no library, contains no code. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* # Shape
*
* Timing tween { duration, ease, delay } | spring { visualDuration,
* bounce, delay } — seconds
* EnterSpec { keyframes, timing?, stagger? } — each property from its
* first keyframe to its last
* EnterPreset fade | rise | grow | wipe | trace | pop
* animation preset | { enter?, update?, trigger? } | false
* enter preset | EnterSpec | false
* update Timing | false — how marks move to new data
* trigger "visible" (default) | "mount"
*
* # Behaviour
*
* R1 A spec is plain data: no functions, no framework. The same spec gives
* the same animation in the React and the Svelte app.
* R2 With reduced motion preferred, nothing animates: an entrance shows its
* end state at once and new data lands without tweening.
* R3 Before its entrance, a component's animated parts are hidden only when
* scripts run and motion is allowed. Without scripts they are visible;
* they never flash at full size and then animate in.
* R4 An entrance runs once per mount. "visible" waits until a quarter of the
* component has scrolled into view; replay by remounting.
* R5 An update tween interrupted by newer data continues from wherever it
* had got to, never from the start.
* R6 Server and client render the same final state; motion begins after
* hydration.
*
* # The animate helper — any element
*
* enter? preset | EnterSpec — once per element
* trigger? "visible" (default) | "mount"
* targets? a selector: animate these descendants in, staggered
* hover? lift | grow | squish | { to, timing } — while pointed at
* press? lift | grow | squish | { to, timing } — while pressed
* change? { on, animation? } — pulse (default) | bump | flash | shake |
* { keyframes, timing }, played each time `on` changes
*
* R7 Hover and press move to a state and back to rest; pressing wins over
* hovering. Hover ignores touch. Press works from the keyboard (Enter,
* Space) on a focusable element.
* R8 A change animation ends where it began, and never plays on first
* render. `on` is compared by identity: pass a primitive.
* R9 Re-rendering never replays an entrance.
* R10 Under reduced motion, gestures and change animations do nothing.
*
* # Presets
*
* fade opacity any mark
* rise opacity + a short upward move any mark
* grow scale from the baseline bars and columns
* wipe revealed left to right lines, areas, sparklines
* trace stroke drawn along its path donut segments
* pop scale from the centre, springy points and small marks
*
* lift up 3px hover pulse scale 1 → 1.08 → 1 change
* grow scale 1.03 hover bump up 6px and back change
* squish scale 0.96 press flash opacity dips change
* shake side to side change
*
* ┌───────────────────────────────────────────────────────────────────────────┐
* │ § MECHANICS — how this implementation meets the contract. │
* └───────────────────────────────────────────────────────────────────────────┘
*
* specs.ts holds the types and presets; run.ts turns them into calls to
* motion's framework-free animate(), stagger(), and inView(). react.ts (and
* the Svelte app's equivalents) only wire those to a component's lifecycle.
*
* The helper: lib/motion/react.tsx: useAnimate(options) returns a ref and props to spread;
* <Animate {...options}> attaches them to its one child through a Slot. The pre-entrance rule for it is lib/motion/motion.css,
* in the override layer, which hides the whole element while it is pending.
*
* R3: components render data-motion-pending; a stylesheet hides their
* [data-mark] elements under @media (scripting: enabled) and
* (prefers-reduced-motion: no-preference). runEnter removes the attribute in
* the same task that starts the animation, so no frame paints in between.
*
* Updates tween the data, not the geometry: a record of numbers is mixed
* frame by frame (a new key starts from zero), and the component redraws
* from the mix. So stacks, arcs, and axes move together with no per-shape
* interpolation.
*/
export {};lib/motion/specs.ts
import type { DOMKeyframesDefinition } from "motion";
/* Animation as data. Nothing here imports a framework: the same specs run in
the React and the Svelte app, through the same runner, so an effect defined
once looks the same in both. */
/** A cubic-bezier, or one of motion's named curves. */
export type Ease =
| readonly [number, number, number, number]
| "linear"
| "easeIn"
| "easeOut"
| "easeInOut"
| "backOut"
| "circOut";
/** How long and how. Seconds throughout. */
export type Timing =
| { type?: "tween"; duration?: number; ease?: Ease; delay?: number }
| {
type: "spring";
/** How long the spring appears to take; the tail settles after. */
visualDuration?: number;
/** 0 is no overshoot; 0.5 is very bouncy. */
bounce?: number;
delay?: number;
};
/** An entrance: every mark goes from its first keyframe to its last. */
export type EnterSpec = {
keyframes: DOMKeyframesDefinition;
timing?: Timing;
/** Seconds between one mark's start and the next's. */
stagger?: number;
};
/** Built-in entrances. Each chart picks a default that suits its marks. */
export type EnterPreset = "fade" | "rise" | "grow" | "wipe" | "trace" | "pop";
export type ChartAnimation = {
/** The marks' entrance; false shows them at once. */
enter?: EnterPreset | EnterSpec | false;
/** How marks move to new data; false jumps. */
update?: Timing | false;
/** Enter on mount, or the first time the chart scrolls into view. */
trigger?: "mount" | "visible";
};
/** What a chart's `animation` prop takes: a preset name, a full spec, or
* false for none. Omitted means the chart's defaults. */
export type AnimationProp = EnterPreset | ChartAnimation | false;
/** The axis a chart's marks grow along, which is what `grow` needs to know. */
export type GrowAxis = "x" | "y";
export const DEFAULT_TIMING = {
duration: 0.6,
ease: [0.22, 1, 0.36, 1],
} satisfies Timing;
export const DEFAULT_UPDATE: Timing = {
duration: 0.45,
ease: [0.22, 1, 0.36, 1],
};
/** The presets as keyframes. `grow` and `wipe` depend on the chart's axis; the
* CSS sets each mark's transform origin at its baseline. */
export function presetSpec(preset: EnterPreset, axis: GrowAxis): EnterSpec {
switch (preset) {
case "fade":
return { keyframes: { opacity: [0, 1] }, stagger: 0.03 };
case "rise":
return {
keyframes: {
opacity: [0, 1],
transform: ["translateY(12px)", "translateY(0px)"],
},
stagger: 0.04,
};
case "grow":
return {
keyframes: {
transform:
axis === "y"
? ["scaleY(0)", "scaleY(1)"]
: ["scaleX(0)", "scaleX(1)"],
},
stagger: 0.04,
};
case "wipe":
return {
keyframes: {
clipPath: ["inset(0 100% 0 0)", "inset(0 0% 0 0)"],
},
timing: { duration: 0.9, ease: [0.45, 0, 0.2, 1] },
stagger: 0.12,
};
case "trace":
// Marks carry pathLength="1" and a dash of 1, so an offset of 1 hides
// the whole stroke and 0 shows it.
return {
keyframes: { strokeDashoffset: [1, 0] },
timing: { duration: 0.5, ease: "easeInOut" },
stagger: 0.5,
};
case "pop":
return {
keyframes: {
opacity: [0, 1],
transform: ["scale(0)", "scale(1)"],
},
timing: { type: "spring", visualDuration: 0.4, bounce: 0.35 },
stagger: 0.02,
};
}
}
/** A chart's `animation` prop, resolved against the chart's defaults. */
export function resolveAnimation(
prop: AnimationProp | undefined,
defaults: { enter: EnterPreset; axis: GrowAxis },
): {
enter: EnterSpec | null;
update: Timing | null;
trigger: "mount" | "visible";
} {
if (prop === false) return { enter: null, update: null, trigger: "mount" };
const options: ChartAnimation =
typeof prop === "string" ? { enter: prop } : (prop ?? {});
const enter = options.enter ?? defaults.enter;
return {
enter:
enter === false
? null
: typeof enter === "string"
? presetSpec(enter, defaults.axis)
: enter,
update:
options.update === false ? null : (options.update ?? DEFAULT_UPDATE),
trigger: options.trigger ?? "visible",
};
}
/* ── The general helper: any element, not only charts ── */
/** Target values, in motion's shorthand: x, y, scale, rotate, opacity… */
export type MotionTarget = Readonly<Record<string, number | string>>;
/** A state an element moves to while hovered or pressed, and back. */
export type GestureSpec = { to: MotionTarget; timing?: Timing };
export type GesturePreset = "lift" | "grow" | "squish";
/** Keyframes played when a watched value changes, ending where they began. */
export type ChangeSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ChangePreset = "pulse" | "bump" | "flash" | "shake";
export type AnimateOptions = {
/** Animate in: a preset or keyframes; false or omitted, no entrance. */
enter?: EnterPreset | EnterSpec | false;
/** When the entrance runs. Default "visible": first scrolled into view. */
trigger?: "mount" | "visible";
/** Animate these descendants in, staggered, instead of the element. */
targets?: string;
/** While the pointer is over it. Ignored on touch. */
hover?: GesturePreset | GestureSpec;
/** While it is pressed: pointer, or Enter/Space when focused. */
press?: GesturePreset | GestureSpec;
/** Play `animation` each time `on` changes (not on first render). */
change?: { on: unknown; animation?: ChangePreset | ChangeSpec };
};
const SNAPPY: Timing = { type: "spring", visualDuration: 0.25, bounce: 0.3 };
export const GESTURES: Record<GesturePreset, GestureSpec> = {
lift: { to: { y: -3 }, timing: SNAPPY },
grow: { to: { scale: 1.03 }, timing: SNAPPY },
squish: { to: { scale: 0.96 }, timing: { duration: 0.1, ease: "easeOut" } },
};
export const CHANGES: Record<ChangePreset, ChangeSpec> = {
pulse: { keyframes: { scale: [1, 1.08, 1] }, timing: { duration: 0.35 } },
bump: { keyframes: { y: [0, -6, 0] }, timing: { duration: 0.35 } },
flash: { keyframes: { opacity: [1, 0.35, 1] }, timing: { duration: 0.5 } },
shake: {
keyframes: { x: [0, -6, 6, -4, 4, 0] },
timing: { duration: 0.4, ease: "easeInOut" },
},
};
export const gestureSpec = (value: GesturePreset | GestureSpec | undefined) =>
typeof value === "string" ? GESTURES[value] : value;
export const changeSpec = (value: ChangePreset | ChangeSpec | undefined) =>
typeof value === "string" ? CHANGES[value] : (value ?? CHANGES.pulse);
export const enterSpec = (value: AnimateOptions["enter"]) =>
!value ? null : typeof value === "string" ? presetSpec(value, "y") : value;
/* ── Exits: an element leaving before it is removed ── */
/** Keyframes from the element's resting state to gone. Most overlays exit
* in CSS (their libraries wait for it); this is for lists a component
* manages itself — toasts. */
export type ExitSpec = { keyframes: DOMKeyframesDefinition; timing?: Timing };
export type ExitPreset = "fade" | "slide-right" | "slide-down" | "shrink";
export const EXITS: Record<ExitPreset, ExitSpec> = {
fade: {
keyframes: { opacity: [1, 0] },
timing: { duration: 0.18, ease: "easeIn" },
},
"slide-right": {
keyframes: {
opacity: [1, 0],
transform: ["translateX(0px)", "translateX(24px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
"slide-down": {
keyframes: {
opacity: [1, 0],
transform: ["translateY(0px)", "translateY(12px)"],
},
timing: { duration: 0.2, ease: "easeIn" },
},
shrink: {
keyframes: { opacity: [1, 0], transform: ["scale(1)", "scale(0.94)"] },
timing: { duration: 0.16, ease: "easeIn" },
},
};
export const exitSpec = (value: ExitPreset | ExitSpec | undefined) =>
typeof value === "string" ? EXITS[value] : (value ?? EXITS.fade);lib/motion/run.ts
import {
animate,
hover,
inView,
press,
stagger,
type DOMKeyframesDefinition,
type Easing,
} from "motion";
import {
changeSpec,
exitSpec,
DEFAULT_TIMING,
gestureSpec,
type AnimateOptions,
type EnterSpec,
type ExitPreset,
type ExitSpec,
type GestureSpec,
type Timing,
} from "./specs";
/* The runner: turns specs into motion calls. Framework-free, so both apps
call exactly this. */
export function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
const DEFAULT_DURATION = 0.6;
/** Timing as motion's options. */
export function toOptions(timing: Timing = DEFAULT_TIMING) {
if (timing.type === "spring") {
return {
type: "spring" as const,
visualDuration: timing.visualDuration ?? 0.5,
bounce: timing.bounce ?? 0.25,
delay: timing.delay ?? 0,
};
}
return {
duration: timing.duration ?? DEFAULT_DURATION,
// Motion's type wants a mutable tuple; the spec's is readonly data.
ease: (timing.ease ?? "easeOut") as Easing,
delay: timing.delay ?? 0,
};
}
/** The attribute a chart renders while its entrance has not run. CSS hides
* the marks under it — only when scripting is on and motion is allowed — so
* they do not flash at full size before animating in, and never stay hidden
* without JavaScript. */
export const PENDING = "data-motion-pending";
const TRANSFORMS = new Set([
"x",
"y",
"z",
"scale",
"scaleX",
"scaleY",
"rotate",
"rotateX",
"rotateY",
"skew",
"skewX",
"skewY",
]);
/** Remove what an entrance of `keyframes` left behind: its finished Web
* Animations, which motion keeps filling forwards (they would override any
* later animation of the same property — a hover lift, a change pulse), and
* the inline styles it committed. */
function clearStyles(mark: Element, keyframes: object) {
for (const animation of mark.getAnimations())
if (animation.playState === "finished") animation.cancel();
const style = (mark as HTMLElement | SVGElement).style;
for (const key of Object.keys(keyframes)) {
const property = TRANSFORMS.has(key)
? "transform"
: key.startsWith("--")
? key
: key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
style.removeProperty(property);
}
}
/** The longest a stagger may spread an entrance, in seconds. */
const MAX_STAGGER = 2;
/** Selects the marks an entrance animates. */
export const MARK = "[data-mark]";
/** Elements whose entrance has started, so a re-attach never replays it. */
const started = new WeakSet<Element>();
/**
* Run an entrance on `root`'s marks (descendants matching `targets`, or the
* root itself when `targets` is null), now or when it first scrolls into
* view. Returns a cleanup that stops it.
*/
export function runEnter(
root: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null = MARK,
): () => void {
let controls: { stop: () => void } | undefined;
const reveal = () => root.removeAttribute(PENDING);
const start = () => {
started.add(root);
const marks = targets ? [...root.querySelectorAll(targets)] : [root];
if (!spec || marks.length === 0 || prefersReducedMotion()) {
reveal();
return;
}
const options = toOptions(spec.timing);
const animation = animate(marks, spec.keyframes, {
...options,
// However many marks, the stagger never adds more than two seconds: a
// few hundred points must not take minutes to arrive.
delay: spec.stagger
? stagger(Math.min(spec.stagger, MAX_STAGGER / marks.length), {
startDelay: options.delay,
})
: options.delay,
});
controls = animation;
// Same task as the animation's first frame, so nothing paints between.
reveal();
// An entrance ends at the mark's natural state, so its inline styles are
// cleared once it finishes: a leftover clip-path or transform would
// otherwise keep clipping strokes or fight the stylesheet.
// Motion commits each element's final style as it finishes, which can
// land after `finished` settles; clearing a frame later runs after it.
animation.finished.then(
() =>
requestAnimationFrame(() =>
marks.forEach((mark) => clearStyles(mark, spec.keyframes)),
),
() => {},
);
};
if (trigger === "mount" || !spec) {
start();
return () => controls?.stop();
}
const stopWatching = inView(
root,
() => {
start();
stopWatching();
},
{ amount: 0.25 },
);
return () => {
stopWatching();
controls?.stop();
};
}
export type NumberRecord = Readonly<Record<string, number>>;
/** Where a key new in the target starts: from zero (a bar growing from its
* baseline) or already at its target (a node placed where it belongs). */
export type Fresh = "zero" | "target";
/** Mix two records by key. A key new in `to` starts from zero or at its
* target (see Fresh); a key missing from `to` is dropped. */
export function mixRecord(
from: NumberRecord,
to: NumberRecord,
t: number,
fresh: Fresh = "zero",
): Record<string, number> {
const out: Record<string, number> = {};
for (const key in to) {
const start = from[key] ?? (fresh === "target" ? to[key] : 0);
out[key] = start + (to[key] - start) * t;
}
return out;
}
/**
* Tween from one record of numbers to another, calling `onFrame` with the mix
* each frame. Returns a stop function. With no timing, or reduced motion, it
* lands on `to` at once.
*/
export function tweenRecord(
from: NumberRecord,
to: NumberRecord,
timing: Timing | null,
onFrame: (value: Record<string, number>) => void,
fresh: Fresh = "zero",
): () => void {
if (!timing || prefersReducedMotion()) {
onFrame({ ...to });
return () => {};
}
const controls = animate(0, 1, {
...toOptions(timing),
onUpdate: (t: number) => onFrame(mixRecord(from, to, t, fresh)),
});
return () => controls.stop();
}
/**
* An entrance that runs at most once per element, however often it is
* attached (a re-render, a strict-mode double effect). The cleanup stops
* waiting for visibility but lets a started animation finish.
*/
export function enterOnce(
element: Element,
spec: EnterSpec | null,
trigger: "mount" | "visible",
targets: string | null,
): () => void {
if (started.has(element)) {
element.removeAttribute(PENDING);
return () => {};
}
let stopWatching = () => {};
const run = () => {
runEnter(element, spec, "mount", targets);
};
if (trigger === "mount" || !spec) run();
else
stopWatching = inView(
element,
() => {
run();
stopWatching();
},
{ amount: 0.25 },
);
return () => stopWatching();
}
/* Motion keeps one `transform` value per element. An entrance that animates
the `transform` string would win over later shorthand keys (y, scale), so
gestures and changes turn their shorthands into a full transform string
too: every animation on an element then moves the same value. */
const SHORTHAND = {
x: 0,
y: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
rotate: 0,
} as const;
type Shorthand = keyof typeof SHORTHAND;
const isShorthand = (key: string): key is Shorthand => key in SHORTHAND;
function transformOf(values: Partial<Record<Shorthand, number>>) {
const at = (key: Shorthand) => values[key] ?? SHORTHAND[key];
const scale = values.scale ?? 1;
return `translate(${at("x")}px, ${at("y")}px) scale(${scale * at("scaleX")}, ${scale * at("scaleY")}) rotate(${at("rotate")}deg)`;
}
/** `keyframes` with any x/y/scale/rotate folded into one `transform`. Values
* may be single or arrays of keyframes; arrays are read index by index. */
export function withTransform(
keyframes: Readonly<Record<string, unknown>>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const shorthands: [Shorthand, number | number[]][] = [];
for (const [key, value] of Object.entries(keyframes)) {
if (isShorthand(key)) shorthands.push([key, value as number | number[]]);
else out[key] = value;
}
if (!shorthands.length) return out;
const frames = Math.max(
...shorthands.map(([, v]) => (Array.isArray(v) ? v.length : 1)),
);
const frame = (index: number) =>
transformOf(
Object.fromEntries(
shorthands.map(([key, v]) => [
key,
Array.isArray(v) ? v[Math.min(index, v.length - 1)] : v,
]),
),
);
out.transform =
frames === 1
? frame(0)
: Array.from({ length: frames }, (_, i) => frame(i));
return out;
}
/** Play keyframes that end where they began: a change, drawn attention to. */
export function playChange(
element: Element,
animation: NonNullable<AnimateOptions["change"]>["animation"],
): () => void {
if (prefersReducedMotion()) return () => {};
const spec = changeSpec(animation);
const controls = animate(
element,
withTransform(
spec.keyframes as Record<string, unknown>,
) as DOMKeyframesDefinition,
toOptions(spec.timing),
);
return () => controls.stop();
}
/** The resting value of a property a gesture moves. */
const REST: Record<string, number> = {
x: 0,
y: 0,
z: 0,
rotate: 0,
rotateX: 0,
rotateY: 0,
skewX: 0,
skewY: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
opacity: 1,
};
/**
* Move to `hover` while the pointer is over the element and to `press` while
* it is pressed (pressing wins), and back to rest after. Nothing under
* reduced motion. Returns a cleanup that unbinds.
*/
export function bindGestures(
element: Element,
hoverSpec: AnimateOptions["hover"],
pressSpec: AnimateOptions["press"],
): () => void {
const onHover = gestureSpec(hoverSpec);
const onPress = gestureSpec(pressSpec);
if ((!onHover && !onPress) || prefersReducedMotion()) return () => {};
const rest: Record<string, number | string> = {};
const style = getComputedStyle(element);
for (const spec of [onHover, onPress])
for (const key of Object.keys(spec?.to ?? {}))
rest[key] = REST[key] ?? style.getPropertyValue(key);
let hovered = false;
let pressed = false;
// Motion reads a value it has never animated from the computed style, and
// reads a computed `none` as a zeroed transform — scale 0. The first move
// therefore starts explicitly from rest.
let first = true;
const restFrame = withTransform(rest).transform;
const settle = (via: GestureSpec | undefined) => {
const target = withTransform({
...rest,
...(hovered ? onHover?.to : {}),
...(pressed ? onPress?.to : {}),
});
if (first && restFrame !== undefined && target.transform !== undefined)
target.transform = [restFrame, target.transform];
first = false;
animate(element, target as DOMKeyframesDefinition, toOptions(via?.timing));
};
const cleanups: (() => void)[] = [];
if (onHover)
cleanups.push(
hover(element, () => {
hovered = true;
settle(onHover);
return () => {
hovered = false;
settle(onHover);
};
}),
);
if (onPress)
cleanups.push(
press(element, () => {
pressed = true;
settle(onPress);
return () => {
pressed = false;
settle(onPress);
};
}),
);
return () => cleanups.forEach((cleanup) => cleanup());
}
/**
* Animate an element out, resolving when it has gone (at once under reduced
* motion). The caller removes it after: `await exitElement(el); remove()`.
*/
export async function exitElement(
element: Element,
exit?: ExitPreset | ExitSpec,
): Promise<void> {
if (prefersReducedMotion()) return;
const spec = exitSpec(exit);
await animate(element, spec.keyframes, toOptions(spec.timing)).finished.catch(
() => {},
);
}lib/motion/react.tsx
"use client";
import { Slot } from "@radix-ui/react-slot";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
type RefObject,
} from "react";
import {
bindGestures,
enterOnce,
PENDING,
playChange,
runEnter,
tweenRecord,
type Fresh,
type NumberRecord,
} from "./run";
import {
enterSpec,
type AnimateOptions,
type EnterSpec,
type Timing,
} from "./specs";
/** Run an entrance once, when the element mounts. Change the element's `key`
* to replay it. */
export function useEnter(
ref: RefObject<Element | null>,
spec: EnterSpec | null,
trigger: "mount" | "visible",
) {
const once = useRef({ spec, trigger });
useEffect(() => {
if (!ref.current) return;
return runEnter(ref.current, once.current.spec, once.current.trigger);
}, [ref]);
}
/** A record of numbers that moves to each new target over `timing`. The first
* render returns the target itself, so server and client agree. A new target
* mid-tween starts from wherever the last one had got to. */
export function useTweened(
target: NumberRecord,
timing: Timing | null,
fresh: Fresh = "zero",
) {
const key = JSON.stringify(target);
// The record is data, so its serialisation is its identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
const to = useMemo(() => target, [key]);
const [shown, setShown] = useState(to);
const current = useRef(to);
useEffect(() => {
if (current.current === to) return;
return tweenRecord(
current.current,
to,
timing,
(value) => {
current.current = value;
setShown(value);
},
fresh,
);
}, [to, timing, fresh]);
return shown;
}
/**
* Animate any element: an entrance, hover and press states, and a flourish
* when a value changes. Returns `[ref, props]`: put the ref on the element
* and spread the props on it
* (they carry the pre-entrance attribute, so nothing flashes on first
* paint). Options are read as data: an entrance runs once; gestures rebind
* when their specs change; `change.on` is compared by identity, so pass a
* primitive.
*/
export function useAnimate<T extends Element = HTMLElement>(
options: AnimateOptions,
) {
const ref = useRef<T>(null);
const initial = useRef(options);
useEffect(() => {
const { enter, trigger = "visible", targets } = initial.current;
if (!ref.current || !enter) return;
return enterOnce(ref.current, enterSpec(enter), trigger, targets ?? null);
}, []);
const gestureKey = JSON.stringify([options.hover, options.press]);
const gestures = useMemo(
() => ({ hover: options.hover, press: options.press }),
// The specs are data, so their serialisation is their identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
[gestureKey],
);
useEffect(() => {
if (!ref.current) return;
return bindGestures(ref.current, gestures.hover, gestures.press);
}, [gestures]);
const on = options.change?.on;
const changeKey = JSON.stringify(options.change?.animation ?? null);
const flourish = useMemo(
() => options.change?.animation,
// eslint-disable-next-line react-hooks/exhaustive-deps
[changeKey],
);
const last = useRef(on);
useEffect(() => {
if (!ref.current || Object.is(last.current, on)) return;
last.current = on;
return playChange(ref.current, flourish);
}, [on, flourish]);
return [ref, { [PENDING]: options.enter ? "enter" : undefined }] as const;
}
export type AnimateProps = AnimateOptions & {
/** One element or component that forwards its ref and props. */
children: ReactElement;
};
/** useAnimate as a wrapper: attaches to its one child, like asChild. */
export function Animate({ children, ...options }: AnimateProps) {
const [animateRef, props] = useAnimate(options);
return (
<Slot ref={animateRef} {...props}>
{children}
</Slot>
);
}lib/motion/motion.css
/* The general animate helper's pre-entrance state: hidden only when scripts
run and motion is allowed, so nothing flashes before animating in and
nothing stays hidden without JavaScript. The runner removes the attribute
as the entrance starts. In `override` so no component style can undo it. */
@layer override {
@media (scripting: enabled) and (prefers-reduced-motion: no-preference) {
[data-motion-pending="enter"] {
opacity: 0;
}
}
}