Motion in use
The animate helper applied to real screens: what moves, when, and why. Motion here always carries meaning — something arrived, changed, or can be pressed — and every screen works the same with it switched off.
Dashboard
staggered entrance · lift on hover · pulse on change
Overview
Cards rise in together, lift under the pointer, and pulse when their number changes.
One entrance for the group. The grid is animated once, with targets staggering its cards, so the page arrives as one piece rather than four separate events. Refresh pulses only the numbers that changed.
Sourcelib/motion/doc.ts · lib/motion/react.tsx · app/kitchen-sink/_sections/motion-recipe-demos.tsx
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/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>
);
}app/kitchen-sink/_sections/motion-recipe-demos.tsx
"use client";
import { useState } from "react";
import { Sparkline } from "@/components/charts/sparkline";
import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { Card } from "@/components/display/card";
import { Stat } from "@/components/display/stat";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input } from "@/components/forms/input";
import { RadioGroup } from "@/components/forms/radio-group";
import { Grid } from "@/components/layout/grid";
import { PageHeader } from "@/components/patterns/page-header";
import { SelectionCard } from "@/components/patterns/selection-card";
import { Text } from "@/components/typography/text";
import type { EnterSpec } from "@/lib/motion";
import { Animate } from "@/lib/motion/react";
import { TREND_DOWN, TREND_UP, varied } from "./chart-fixtures";
/** A short rise, delayed by position: an entrance for items that arrive
* together, while ones added later come in at once. */
const riseAfter = (delay: number): EnterSpec => ({
keyframes: {
opacity: [0, 1],
transform: ["translateY(10px)", "translateY(0px)"],
},
timing: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay },
});
const METRICS = [
{ key: "active", label: "Active users", base: 68, trend: TREND_UP },
{
key: "errors",
label: "Error rate",
base: 23,
trend: TREND_DOWN,
unit: "‰",
},
{ key: "deploys", label: "Deploys", base: 147, trend: TREND_UP.slice(10) },
{ key: "seats", label: "Seats used", base: 41, trend: TREND_UP.slice(4) },
];
export function DashboardRecipe() {
const [refresh, setRefresh] = useState(0);
return (
<div className="ks-fill ks-stack">
<PageHeader
level={3}
size="md"
title="Overview"
description="Cards rise in together, lift under the pointer, and pulse when their number changes."
actions={
<Animate press="squish">
<Button size="sm" onClick={() => setRefresh((n) => n + 1)}>
Refresh
</Button>
</Animate>
}
/>
<Animate enter={{ ...riseAfter(0), stagger: 0.07 }} targets="[data-card]">
<div>
<Grid min="11rem">
{METRICS.map((metric, index) => {
const value = refresh
? varied(refresh + index, [metric.base])[0]
: metric.base;
return (
<Animate key={metric.key} hover="lift">
<Card as="div" className="ks-stat-card" data-card>
<Animate change={{ on: value, animation: "pulse" }}>
<div className="ks-origin-start">
<Stat
label={metric.label}
value={`${value}${metric.unit ?? ""}`}
/>
</div>
</Animate>
<Sparkline
label={`${metric.label}, last 30 days`}
values={metric.trend}
color={index === 1 ? 2 : 1}
/>
</Card>
</Animate>
);
})}
</Grid>
</div>
</Animate>
</div>
);
}
type Event = { id: number; who: string; what: string; when: string };
const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman"];
const ACTIONS = [
"deployed atlas-api to production",
"invited 2 members",
"rotated an API key",
"closed incident #214",
"upgraded the plan to Team",
];
const INITIAL: Event[] = [0, 1, 2, 3].map((index) => ({
id: index,
who: PEOPLE[index],
what: ACTIONS[index],
when: `${(index + 1) * 7} min ago`,
}));
export function FeedRecipe() {
const [events, setEvents] = useState(INITIAL);
const unread = events.length - INITIAL.length;
const add = () =>
setEvents((list) => [
{
id: list.length,
who: PEOPLE[list.length % PEOPLE.length],
what: ACTIONS[list.length % ACTIONS.length],
when: "just now",
},
...list,
]);
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Activity</Text>
<Animate change={{ on: unread, animation: "pulse" }}>
<span className="ks-motion-count">
<Badge tone={unread ? "accent" : "neutral"}>{unread} new</Badge>
</span>
</Animate>
<Button size="sm" onClick={add} className="ks-push-end">
Simulate event
</Button>
</div>
<ul className="ks-feed-list" aria-live="polite">
{events.map((event, index) => (
<Animate
key={event.id}
// The first render's items arrive together and stagger; one added
// later enters at once. Either way, only once per item.
enter={riseAfter(event.id < INITIAL.length ? index * 0.06 : 0)}
trigger="mount"
>
<li className="ks-feed-item">
<Avatar name={event.who} size="sm" />
<Text size="sm">
<strong>{event.who}</strong> {event.what}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
{event.when}
</Text>
</li>
</Animate>
))}
</ul>
</Card>
);
}
const PLANS = [
{ value: "starter", label: "Starter", price: 0, note: "For trying Bento." },
{ value: "team", label: "Team", price: 249, note: "Up to 50 seats." },
{
value: "business",
label: "Business",
price: 799,
note: "SSO and audit logs.",
},
];
export function PlanRecipe() {
const [plan, setPlan] = useState("team");
const chosen = PLANS.find((item) => item.value === plan)!;
return (
<div className="ks-fill ks-stack">
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
<Grid min="12rem">
{PLANS.map((item) => (
<Animate key={item.value} hover="lift">
<div>
<SelectionCard
mode="single"
value={item.value}
label={item.label}
description={item.note}
>
<Text weight="strong">${item.price} / month</Text>
</SelectionCard>
</div>
</Animate>
))}
</Grid>
</RadioGroup>
<div className="ks-row-tight">
<Animate change={{ on: plan, animation: "bump" }}>
<span>
<Text>
Total today: <strong>${chosen.price}</strong>
</Text>
</span>
</Animate>
<Animate press="squish">
<Button variant="primary" className="ks-push-end">
Continue with {chosen.label}
</Button>
</Animate>
</div>
</div>
);
}
export function SaveRecipe() {
const [name, setName] = useState("");
const [attempt, setAttempt] = useState(0);
// Counts failed submits only: the shake plays per failure, never when the
// form becomes valid.
const [failures, setFailures] = useState(0);
const [saved, setSaved] = useState<string | null>(null);
const invalid = attempt > 0 && !saved && name.trim().length < 3;
return (
<Card as="div" className="ks-motion-card ks-fill">
<form
className="ks-stack"
noValidate
onSubmit={(event) => {
event.preventDefault();
const ok = name.trim().length >= 3;
setAttempt((n) => n + 1);
if (!ok) setFailures((n) => n + 1);
setSaved(ok ? name.trim() : null);
}}
>
<Animate change={{ on: failures, animation: "shake" }}>
<div>
<Field
label="Workspace name"
hint="At least three characters."
error={invalid ? "That name is too short." : undefined}
>
{(control) => (
<Input
{...control}
value={name}
onChange={(event) => {
setName(event.target.value);
setSaved(null);
}}
/>
)}
</Field>
</div>
</Animate>
<div className="ks-row-tight">
<Animate press="squish">
<Button type="submit" variant="primary">
Save
</Button>
</Animate>
</div>
{saved ? (
<Animate key={attempt} enter="rise" trigger="mount">
<div>
<Alert tone="accent" live="polite" title="Saved">
The workspace is now called “{saved}”.
</Alert>
</div>
</Animate>
) : null}
</form>
</Card>
);
}
const PROJECTS = ["atlas-api", "juniper-web", "northstar-docs", "orion-worker"];
export function LoadingRecipe() {
const [state, setState] = useState<"idle" | "loading" | "ready">("ready");
const [round, setRound] = useState(0);
const load = () => {
setState("loading");
setTimeout(() => {
setState("ready");
setRound((n) => n + 1);
}, 900);
};
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Projects</Text>
<Button
size="sm"
onClick={load}
loading={state === "loading"}
className="ks-push-end"
>
Reload
</Button>
</div>
{state === "loading" ? (
<ul className="ks-feed-list" aria-busy="true">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Skeleton width="40%" />
</li>
))}
</ul>
) : (
<Animate
key={round}
enter={{ ...riseAfter(0), stagger: 0.06 }}
targets="li"
trigger="mount"
>
<ul className="ks-feed-list">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Text size="sm" weight="strong">
{name}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
deployed {(PROJECTS.indexOf(name) + 1) * 3}h ago
</Text>
</li>
))}
</ul>
</Animate>
)}
</Card>
);
}Activity feed
new items enter · existing ones do not replay
Activity
0 new- Ada Lovelace
Ada Lovelace deployed atlas-api to production
7 min ago
- Grace Hopper
Grace Hopper invited 2 members
14 min ago
- Alan Turing
Alan Turing rotated an API key
21 min ago
- Radia Perlman
Radia Perlman closed incident #214
28 min ago
An entrance is per element. Each item enters once when it mounts; adding one at the top animates only it. The first render staggers by position, later arrivals come in at once.
Sourcelib/motion/doc.ts · lib/motion/react.tsx · app/kitchen-sink/_sections/motion-recipe-demos.tsx
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/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>
);
}app/kitchen-sink/_sections/motion-recipe-demos.tsx
"use client";
import { useState } from "react";
import { Sparkline } from "@/components/charts/sparkline";
import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { Card } from "@/components/display/card";
import { Stat } from "@/components/display/stat";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input } from "@/components/forms/input";
import { RadioGroup } from "@/components/forms/radio-group";
import { Grid } from "@/components/layout/grid";
import { PageHeader } from "@/components/patterns/page-header";
import { SelectionCard } from "@/components/patterns/selection-card";
import { Text } from "@/components/typography/text";
import type { EnterSpec } from "@/lib/motion";
import { Animate } from "@/lib/motion/react";
import { TREND_DOWN, TREND_UP, varied } from "./chart-fixtures";
/** A short rise, delayed by position: an entrance for items that arrive
* together, while ones added later come in at once. */
const riseAfter = (delay: number): EnterSpec => ({
keyframes: {
opacity: [0, 1],
transform: ["translateY(10px)", "translateY(0px)"],
},
timing: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay },
});
const METRICS = [
{ key: "active", label: "Active users", base: 68, trend: TREND_UP },
{
key: "errors",
label: "Error rate",
base: 23,
trend: TREND_DOWN,
unit: "‰",
},
{ key: "deploys", label: "Deploys", base: 147, trend: TREND_UP.slice(10) },
{ key: "seats", label: "Seats used", base: 41, trend: TREND_UP.slice(4) },
];
export function DashboardRecipe() {
const [refresh, setRefresh] = useState(0);
return (
<div className="ks-fill ks-stack">
<PageHeader
level={3}
size="md"
title="Overview"
description="Cards rise in together, lift under the pointer, and pulse when their number changes."
actions={
<Animate press="squish">
<Button size="sm" onClick={() => setRefresh((n) => n + 1)}>
Refresh
</Button>
</Animate>
}
/>
<Animate enter={{ ...riseAfter(0), stagger: 0.07 }} targets="[data-card]">
<div>
<Grid min="11rem">
{METRICS.map((metric, index) => {
const value = refresh
? varied(refresh + index, [metric.base])[0]
: metric.base;
return (
<Animate key={metric.key} hover="lift">
<Card as="div" className="ks-stat-card" data-card>
<Animate change={{ on: value, animation: "pulse" }}>
<div className="ks-origin-start">
<Stat
label={metric.label}
value={`${value}${metric.unit ?? ""}`}
/>
</div>
</Animate>
<Sparkline
label={`${metric.label}, last 30 days`}
values={metric.trend}
color={index === 1 ? 2 : 1}
/>
</Card>
</Animate>
);
})}
</Grid>
</div>
</Animate>
</div>
);
}
type Event = { id: number; who: string; what: string; when: string };
const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman"];
const ACTIONS = [
"deployed atlas-api to production",
"invited 2 members",
"rotated an API key",
"closed incident #214",
"upgraded the plan to Team",
];
const INITIAL: Event[] = [0, 1, 2, 3].map((index) => ({
id: index,
who: PEOPLE[index],
what: ACTIONS[index],
when: `${(index + 1) * 7} min ago`,
}));
export function FeedRecipe() {
const [events, setEvents] = useState(INITIAL);
const unread = events.length - INITIAL.length;
const add = () =>
setEvents((list) => [
{
id: list.length,
who: PEOPLE[list.length % PEOPLE.length],
what: ACTIONS[list.length % ACTIONS.length],
when: "just now",
},
...list,
]);
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Activity</Text>
<Animate change={{ on: unread, animation: "pulse" }}>
<span className="ks-motion-count">
<Badge tone={unread ? "accent" : "neutral"}>{unread} new</Badge>
</span>
</Animate>
<Button size="sm" onClick={add} className="ks-push-end">
Simulate event
</Button>
</div>
<ul className="ks-feed-list" aria-live="polite">
{events.map((event, index) => (
<Animate
key={event.id}
// The first render's items arrive together and stagger; one added
// later enters at once. Either way, only once per item.
enter={riseAfter(event.id < INITIAL.length ? index * 0.06 : 0)}
trigger="mount"
>
<li className="ks-feed-item">
<Avatar name={event.who} size="sm" />
<Text size="sm">
<strong>{event.who}</strong> {event.what}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
{event.when}
</Text>
</li>
</Animate>
))}
</ul>
</Card>
);
}
const PLANS = [
{ value: "starter", label: "Starter", price: 0, note: "For trying Bento." },
{ value: "team", label: "Team", price: 249, note: "Up to 50 seats." },
{
value: "business",
label: "Business",
price: 799,
note: "SSO and audit logs.",
},
];
export function PlanRecipe() {
const [plan, setPlan] = useState("team");
const chosen = PLANS.find((item) => item.value === plan)!;
return (
<div className="ks-fill ks-stack">
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
<Grid min="12rem">
{PLANS.map((item) => (
<Animate key={item.value} hover="lift">
<div>
<SelectionCard
mode="single"
value={item.value}
label={item.label}
description={item.note}
>
<Text weight="strong">${item.price} / month</Text>
</SelectionCard>
</div>
</Animate>
))}
</Grid>
</RadioGroup>
<div className="ks-row-tight">
<Animate change={{ on: plan, animation: "bump" }}>
<span>
<Text>
Total today: <strong>${chosen.price}</strong>
</Text>
</span>
</Animate>
<Animate press="squish">
<Button variant="primary" className="ks-push-end">
Continue with {chosen.label}
</Button>
</Animate>
</div>
</div>
);
}
export function SaveRecipe() {
const [name, setName] = useState("");
const [attempt, setAttempt] = useState(0);
// Counts failed submits only: the shake plays per failure, never when the
// form becomes valid.
const [failures, setFailures] = useState(0);
const [saved, setSaved] = useState<string | null>(null);
const invalid = attempt > 0 && !saved && name.trim().length < 3;
return (
<Card as="div" className="ks-motion-card ks-fill">
<form
className="ks-stack"
noValidate
onSubmit={(event) => {
event.preventDefault();
const ok = name.trim().length >= 3;
setAttempt((n) => n + 1);
if (!ok) setFailures((n) => n + 1);
setSaved(ok ? name.trim() : null);
}}
>
<Animate change={{ on: failures, animation: "shake" }}>
<div>
<Field
label="Workspace name"
hint="At least three characters."
error={invalid ? "That name is too short." : undefined}
>
{(control) => (
<Input
{...control}
value={name}
onChange={(event) => {
setName(event.target.value);
setSaved(null);
}}
/>
)}
</Field>
</div>
</Animate>
<div className="ks-row-tight">
<Animate press="squish">
<Button type="submit" variant="primary">
Save
</Button>
</Animate>
</div>
{saved ? (
<Animate key={attempt} enter="rise" trigger="mount">
<div>
<Alert tone="accent" live="polite" title="Saved">
The workspace is now called “{saved}”.
</Alert>
</div>
</Animate>
) : null}
</form>
</Card>
);
}
const PROJECTS = ["atlas-api", "juniper-web", "northstar-docs", "orion-worker"];
export function LoadingRecipe() {
const [state, setState] = useState<"idle" | "loading" | "ready">("ready");
const [round, setRound] = useState(0);
const load = () => {
setState("loading");
setTimeout(() => {
setState("ready");
setRound((n) => n + 1);
}, 900);
};
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Projects</Text>
<Button
size="sm"
onClick={load}
loading={state === "loading"}
className="ks-push-end"
>
Reload
</Button>
</div>
{state === "loading" ? (
<ul className="ks-feed-list" aria-busy="true">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Skeleton width="40%" />
</li>
))}
</ul>
) : (
<Animate
key={round}
enter={{ ...riseAfter(0), stagger: 0.06 }}
targets="li"
trigger="mount"
>
<ul className="ks-feed-list">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Text size="sm" weight="strong">
{name}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
deployed {(PROJECTS.indexOf(name) + 1) * 3}h ago
</Text>
</li>
))}
</ul>
</Animate>
)}
</Card>
);
}Plan picker
lift · squish · the total bumps when it changes
For trying Bento.
$0 / month
Up to 50 seats.
$249 / month
SSO and audit logs.
$799 / month
Total today: $249
Sourcelib/motion/doc.ts · lib/motion/react.tsx · app/kitchen-sink/_sections/motion-recipe-demos.tsx
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/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>
);
}app/kitchen-sink/_sections/motion-recipe-demos.tsx
"use client";
import { useState } from "react";
import { Sparkline } from "@/components/charts/sparkline";
import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { Card } from "@/components/display/card";
import { Stat } from "@/components/display/stat";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input } from "@/components/forms/input";
import { RadioGroup } from "@/components/forms/radio-group";
import { Grid } from "@/components/layout/grid";
import { PageHeader } from "@/components/patterns/page-header";
import { SelectionCard } from "@/components/patterns/selection-card";
import { Text } from "@/components/typography/text";
import type { EnterSpec } from "@/lib/motion";
import { Animate } from "@/lib/motion/react";
import { TREND_DOWN, TREND_UP, varied } from "./chart-fixtures";
/** A short rise, delayed by position: an entrance for items that arrive
* together, while ones added later come in at once. */
const riseAfter = (delay: number): EnterSpec => ({
keyframes: {
opacity: [0, 1],
transform: ["translateY(10px)", "translateY(0px)"],
},
timing: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay },
});
const METRICS = [
{ key: "active", label: "Active users", base: 68, trend: TREND_UP },
{
key: "errors",
label: "Error rate",
base: 23,
trend: TREND_DOWN,
unit: "‰",
},
{ key: "deploys", label: "Deploys", base: 147, trend: TREND_UP.slice(10) },
{ key: "seats", label: "Seats used", base: 41, trend: TREND_UP.slice(4) },
];
export function DashboardRecipe() {
const [refresh, setRefresh] = useState(0);
return (
<div className="ks-fill ks-stack">
<PageHeader
level={3}
size="md"
title="Overview"
description="Cards rise in together, lift under the pointer, and pulse when their number changes."
actions={
<Animate press="squish">
<Button size="sm" onClick={() => setRefresh((n) => n + 1)}>
Refresh
</Button>
</Animate>
}
/>
<Animate enter={{ ...riseAfter(0), stagger: 0.07 }} targets="[data-card]">
<div>
<Grid min="11rem">
{METRICS.map((metric, index) => {
const value = refresh
? varied(refresh + index, [metric.base])[0]
: metric.base;
return (
<Animate key={metric.key} hover="lift">
<Card as="div" className="ks-stat-card" data-card>
<Animate change={{ on: value, animation: "pulse" }}>
<div className="ks-origin-start">
<Stat
label={metric.label}
value={`${value}${metric.unit ?? ""}`}
/>
</div>
</Animate>
<Sparkline
label={`${metric.label}, last 30 days`}
values={metric.trend}
color={index === 1 ? 2 : 1}
/>
</Card>
</Animate>
);
})}
</Grid>
</div>
</Animate>
</div>
);
}
type Event = { id: number; who: string; what: string; when: string };
const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman"];
const ACTIONS = [
"deployed atlas-api to production",
"invited 2 members",
"rotated an API key",
"closed incident #214",
"upgraded the plan to Team",
];
const INITIAL: Event[] = [0, 1, 2, 3].map((index) => ({
id: index,
who: PEOPLE[index],
what: ACTIONS[index],
when: `${(index + 1) * 7} min ago`,
}));
export function FeedRecipe() {
const [events, setEvents] = useState(INITIAL);
const unread = events.length - INITIAL.length;
const add = () =>
setEvents((list) => [
{
id: list.length,
who: PEOPLE[list.length % PEOPLE.length],
what: ACTIONS[list.length % ACTIONS.length],
when: "just now",
},
...list,
]);
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Activity</Text>
<Animate change={{ on: unread, animation: "pulse" }}>
<span className="ks-motion-count">
<Badge tone={unread ? "accent" : "neutral"}>{unread} new</Badge>
</span>
</Animate>
<Button size="sm" onClick={add} className="ks-push-end">
Simulate event
</Button>
</div>
<ul className="ks-feed-list" aria-live="polite">
{events.map((event, index) => (
<Animate
key={event.id}
// The first render's items arrive together and stagger; one added
// later enters at once. Either way, only once per item.
enter={riseAfter(event.id < INITIAL.length ? index * 0.06 : 0)}
trigger="mount"
>
<li className="ks-feed-item">
<Avatar name={event.who} size="sm" />
<Text size="sm">
<strong>{event.who}</strong> {event.what}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
{event.when}
</Text>
</li>
</Animate>
))}
</ul>
</Card>
);
}
const PLANS = [
{ value: "starter", label: "Starter", price: 0, note: "For trying Bento." },
{ value: "team", label: "Team", price: 249, note: "Up to 50 seats." },
{
value: "business",
label: "Business",
price: 799,
note: "SSO and audit logs.",
},
];
export function PlanRecipe() {
const [plan, setPlan] = useState("team");
const chosen = PLANS.find((item) => item.value === plan)!;
return (
<div className="ks-fill ks-stack">
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
<Grid min="12rem">
{PLANS.map((item) => (
<Animate key={item.value} hover="lift">
<div>
<SelectionCard
mode="single"
value={item.value}
label={item.label}
description={item.note}
>
<Text weight="strong">${item.price} / month</Text>
</SelectionCard>
</div>
</Animate>
))}
</Grid>
</RadioGroup>
<div className="ks-row-tight">
<Animate change={{ on: plan, animation: "bump" }}>
<span>
<Text>
Total today: <strong>${chosen.price}</strong>
</Text>
</span>
</Animate>
<Animate press="squish">
<Button variant="primary" className="ks-push-end">
Continue with {chosen.label}
</Button>
</Animate>
</div>
</div>
);
}
export function SaveRecipe() {
const [name, setName] = useState("");
const [attempt, setAttempt] = useState(0);
// Counts failed submits only: the shake plays per failure, never when the
// form becomes valid.
const [failures, setFailures] = useState(0);
const [saved, setSaved] = useState<string | null>(null);
const invalid = attempt > 0 && !saved && name.trim().length < 3;
return (
<Card as="div" className="ks-motion-card ks-fill">
<form
className="ks-stack"
noValidate
onSubmit={(event) => {
event.preventDefault();
const ok = name.trim().length >= 3;
setAttempt((n) => n + 1);
if (!ok) setFailures((n) => n + 1);
setSaved(ok ? name.trim() : null);
}}
>
<Animate change={{ on: failures, animation: "shake" }}>
<div>
<Field
label="Workspace name"
hint="At least three characters."
error={invalid ? "That name is too short." : undefined}
>
{(control) => (
<Input
{...control}
value={name}
onChange={(event) => {
setName(event.target.value);
setSaved(null);
}}
/>
)}
</Field>
</div>
</Animate>
<div className="ks-row-tight">
<Animate press="squish">
<Button type="submit" variant="primary">
Save
</Button>
</Animate>
</div>
{saved ? (
<Animate key={attempt} enter="rise" trigger="mount">
<div>
<Alert tone="accent" live="polite" title="Saved">
The workspace is now called “{saved}”.
</Alert>
</div>
</Animate>
) : null}
</form>
</Card>
);
}
const PROJECTS = ["atlas-api", "juniper-web", "northstar-docs", "orion-worker"];
export function LoadingRecipe() {
const [state, setState] = useState<"idle" | "loading" | "ready">("ready");
const [round, setRound] = useState(0);
const load = () => {
setState("loading");
setTimeout(() => {
setState("ready");
setRound((n) => n + 1);
}, 900);
};
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Projects</Text>
<Button
size="sm"
onClick={load}
loading={state === "loading"}
className="ks-push-end"
>
Reload
</Button>
</div>
{state === "loading" ? (
<ul className="ks-feed-list" aria-busy="true">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Skeleton width="40%" />
</li>
))}
</ul>
) : (
<Animate
key={round}
enter={{ ...riseAfter(0), stagger: 0.06 }}
targets="li"
trigger="mount"
>
<ul className="ks-feed-list">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Text size="sm" weight="strong">
{name}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
deployed {(PROJECTS.indexOf(name) + 1) * 3}h ago
</Text>
</li>
))}
</ul>
</Animate>
)}
</Card>
);
}Save flow
shake on error · confirmation rises in
Motion points; text says. The shake draws the eye to the field, but the error message is what explains it, and it is there with or without motion.
Sourcelib/motion/doc.ts · lib/motion/react.tsx · app/kitchen-sink/_sections/motion-recipe-demos.tsx
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/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>
);
}app/kitchen-sink/_sections/motion-recipe-demos.tsx
"use client";
import { useState } from "react";
import { Sparkline } from "@/components/charts/sparkline";
import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { Card } from "@/components/display/card";
import { Stat } from "@/components/display/stat";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input } from "@/components/forms/input";
import { RadioGroup } from "@/components/forms/radio-group";
import { Grid } from "@/components/layout/grid";
import { PageHeader } from "@/components/patterns/page-header";
import { SelectionCard } from "@/components/patterns/selection-card";
import { Text } from "@/components/typography/text";
import type { EnterSpec } from "@/lib/motion";
import { Animate } from "@/lib/motion/react";
import { TREND_DOWN, TREND_UP, varied } from "./chart-fixtures";
/** A short rise, delayed by position: an entrance for items that arrive
* together, while ones added later come in at once. */
const riseAfter = (delay: number): EnterSpec => ({
keyframes: {
opacity: [0, 1],
transform: ["translateY(10px)", "translateY(0px)"],
},
timing: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay },
});
const METRICS = [
{ key: "active", label: "Active users", base: 68, trend: TREND_UP },
{
key: "errors",
label: "Error rate",
base: 23,
trend: TREND_DOWN,
unit: "‰",
},
{ key: "deploys", label: "Deploys", base: 147, trend: TREND_UP.slice(10) },
{ key: "seats", label: "Seats used", base: 41, trend: TREND_UP.slice(4) },
];
export function DashboardRecipe() {
const [refresh, setRefresh] = useState(0);
return (
<div className="ks-fill ks-stack">
<PageHeader
level={3}
size="md"
title="Overview"
description="Cards rise in together, lift under the pointer, and pulse when their number changes."
actions={
<Animate press="squish">
<Button size="sm" onClick={() => setRefresh((n) => n + 1)}>
Refresh
</Button>
</Animate>
}
/>
<Animate enter={{ ...riseAfter(0), stagger: 0.07 }} targets="[data-card]">
<div>
<Grid min="11rem">
{METRICS.map((metric, index) => {
const value = refresh
? varied(refresh + index, [metric.base])[0]
: metric.base;
return (
<Animate key={metric.key} hover="lift">
<Card as="div" className="ks-stat-card" data-card>
<Animate change={{ on: value, animation: "pulse" }}>
<div className="ks-origin-start">
<Stat
label={metric.label}
value={`${value}${metric.unit ?? ""}`}
/>
</div>
</Animate>
<Sparkline
label={`${metric.label}, last 30 days`}
values={metric.trend}
color={index === 1 ? 2 : 1}
/>
</Card>
</Animate>
);
})}
</Grid>
</div>
</Animate>
</div>
);
}
type Event = { id: number; who: string; what: string; when: string };
const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman"];
const ACTIONS = [
"deployed atlas-api to production",
"invited 2 members",
"rotated an API key",
"closed incident #214",
"upgraded the plan to Team",
];
const INITIAL: Event[] = [0, 1, 2, 3].map((index) => ({
id: index,
who: PEOPLE[index],
what: ACTIONS[index],
when: `${(index + 1) * 7} min ago`,
}));
export function FeedRecipe() {
const [events, setEvents] = useState(INITIAL);
const unread = events.length - INITIAL.length;
const add = () =>
setEvents((list) => [
{
id: list.length,
who: PEOPLE[list.length % PEOPLE.length],
what: ACTIONS[list.length % ACTIONS.length],
when: "just now",
},
...list,
]);
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Activity</Text>
<Animate change={{ on: unread, animation: "pulse" }}>
<span className="ks-motion-count">
<Badge tone={unread ? "accent" : "neutral"}>{unread} new</Badge>
</span>
</Animate>
<Button size="sm" onClick={add} className="ks-push-end">
Simulate event
</Button>
</div>
<ul className="ks-feed-list" aria-live="polite">
{events.map((event, index) => (
<Animate
key={event.id}
// The first render's items arrive together and stagger; one added
// later enters at once. Either way, only once per item.
enter={riseAfter(event.id < INITIAL.length ? index * 0.06 : 0)}
trigger="mount"
>
<li className="ks-feed-item">
<Avatar name={event.who} size="sm" />
<Text size="sm">
<strong>{event.who}</strong> {event.what}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
{event.when}
</Text>
</li>
</Animate>
))}
</ul>
</Card>
);
}
const PLANS = [
{ value: "starter", label: "Starter", price: 0, note: "For trying Bento." },
{ value: "team", label: "Team", price: 249, note: "Up to 50 seats." },
{
value: "business",
label: "Business",
price: 799,
note: "SSO and audit logs.",
},
];
export function PlanRecipe() {
const [plan, setPlan] = useState("team");
const chosen = PLANS.find((item) => item.value === plan)!;
return (
<div className="ks-fill ks-stack">
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
<Grid min="12rem">
{PLANS.map((item) => (
<Animate key={item.value} hover="lift">
<div>
<SelectionCard
mode="single"
value={item.value}
label={item.label}
description={item.note}
>
<Text weight="strong">${item.price} / month</Text>
</SelectionCard>
</div>
</Animate>
))}
</Grid>
</RadioGroup>
<div className="ks-row-tight">
<Animate change={{ on: plan, animation: "bump" }}>
<span>
<Text>
Total today: <strong>${chosen.price}</strong>
</Text>
</span>
</Animate>
<Animate press="squish">
<Button variant="primary" className="ks-push-end">
Continue with {chosen.label}
</Button>
</Animate>
</div>
</div>
);
}
export function SaveRecipe() {
const [name, setName] = useState("");
const [attempt, setAttempt] = useState(0);
// Counts failed submits only: the shake plays per failure, never when the
// form becomes valid.
const [failures, setFailures] = useState(0);
const [saved, setSaved] = useState<string | null>(null);
const invalid = attempt > 0 && !saved && name.trim().length < 3;
return (
<Card as="div" className="ks-motion-card ks-fill">
<form
className="ks-stack"
noValidate
onSubmit={(event) => {
event.preventDefault();
const ok = name.trim().length >= 3;
setAttempt((n) => n + 1);
if (!ok) setFailures((n) => n + 1);
setSaved(ok ? name.trim() : null);
}}
>
<Animate change={{ on: failures, animation: "shake" }}>
<div>
<Field
label="Workspace name"
hint="At least three characters."
error={invalid ? "That name is too short." : undefined}
>
{(control) => (
<Input
{...control}
value={name}
onChange={(event) => {
setName(event.target.value);
setSaved(null);
}}
/>
)}
</Field>
</div>
</Animate>
<div className="ks-row-tight">
<Animate press="squish">
<Button type="submit" variant="primary">
Save
</Button>
</Animate>
</div>
{saved ? (
<Animate key={attempt} enter="rise" trigger="mount">
<div>
<Alert tone="accent" live="polite" title="Saved">
The workspace is now called “{saved}”.
</Alert>
</div>
</Animate>
) : null}
</form>
</Card>
);
}
const PROJECTS = ["atlas-api", "juniper-web", "northstar-docs", "orion-worker"];
export function LoadingRecipe() {
const [state, setState] = useState<"idle" | "loading" | "ready">("ready");
const [round, setRound] = useState(0);
const load = () => {
setState("loading");
setTimeout(() => {
setState("ready");
setRound((n) => n + 1);
}, 900);
};
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Projects</Text>
<Button
size="sm"
onClick={load}
loading={state === "loading"}
className="ks-push-end"
>
Reload
</Button>
</div>
{state === "loading" ? (
<ul className="ks-feed-list" aria-busy="true">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Skeleton width="40%" />
</li>
))}
</ul>
) : (
<Animate
key={round}
enter={{ ...riseAfter(0), stagger: 0.06 }}
targets="li"
trigger="mount"
>
<ul className="ks-feed-list">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Text size="sm" weight="strong">
{name}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
deployed {(PROJECTS.indexOf(name) + 1) * 3}h ago
</Text>
</li>
))}
</ul>
</Animate>
)}
</Card>
);
}Loading into content
skeleton, then rows stagger in
Projects
atlas-api
deployed 3h ago
juniper-web
deployed 6h ago
northstar-docs
deployed 9h ago
orion-worker
deployed 12h ago
Sourcelib/motion/doc.ts · lib/motion/react.tsx · app/kitchen-sink/_sections/motion-recipe-demos.tsx
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/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>
);
}app/kitchen-sink/_sections/motion-recipe-demos.tsx
"use client";
import { useState } from "react";
import { Sparkline } from "@/components/charts/sparkline";
import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { Card } from "@/components/display/card";
import { Stat } from "@/components/display/stat";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input } from "@/components/forms/input";
import { RadioGroup } from "@/components/forms/radio-group";
import { Grid } from "@/components/layout/grid";
import { PageHeader } from "@/components/patterns/page-header";
import { SelectionCard } from "@/components/patterns/selection-card";
import { Text } from "@/components/typography/text";
import type { EnterSpec } from "@/lib/motion";
import { Animate } from "@/lib/motion/react";
import { TREND_DOWN, TREND_UP, varied } from "./chart-fixtures";
/** A short rise, delayed by position: an entrance for items that arrive
* together, while ones added later come in at once. */
const riseAfter = (delay: number): EnterSpec => ({
keyframes: {
opacity: [0, 1],
transform: ["translateY(10px)", "translateY(0px)"],
},
timing: { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay },
});
const METRICS = [
{ key: "active", label: "Active users", base: 68, trend: TREND_UP },
{
key: "errors",
label: "Error rate",
base: 23,
trend: TREND_DOWN,
unit: "‰",
},
{ key: "deploys", label: "Deploys", base: 147, trend: TREND_UP.slice(10) },
{ key: "seats", label: "Seats used", base: 41, trend: TREND_UP.slice(4) },
];
export function DashboardRecipe() {
const [refresh, setRefresh] = useState(0);
return (
<div className="ks-fill ks-stack">
<PageHeader
level={3}
size="md"
title="Overview"
description="Cards rise in together, lift under the pointer, and pulse when their number changes."
actions={
<Animate press="squish">
<Button size="sm" onClick={() => setRefresh((n) => n + 1)}>
Refresh
</Button>
</Animate>
}
/>
<Animate enter={{ ...riseAfter(0), stagger: 0.07 }} targets="[data-card]">
<div>
<Grid min="11rem">
{METRICS.map((metric, index) => {
const value = refresh
? varied(refresh + index, [metric.base])[0]
: metric.base;
return (
<Animate key={metric.key} hover="lift">
<Card as="div" className="ks-stat-card" data-card>
<Animate change={{ on: value, animation: "pulse" }}>
<div className="ks-origin-start">
<Stat
label={metric.label}
value={`${value}${metric.unit ?? ""}`}
/>
</div>
</Animate>
<Sparkline
label={`${metric.label}, last 30 days`}
values={metric.trend}
color={index === 1 ? 2 : 1}
/>
</Card>
</Animate>
);
})}
</Grid>
</div>
</Animate>
</div>
);
}
type Event = { id: number; who: string; what: string; when: string };
const PEOPLE = ["Ada Lovelace", "Grace Hopper", "Alan Turing", "Radia Perlman"];
const ACTIONS = [
"deployed atlas-api to production",
"invited 2 members",
"rotated an API key",
"closed incident #214",
"upgraded the plan to Team",
];
const INITIAL: Event[] = [0, 1, 2, 3].map((index) => ({
id: index,
who: PEOPLE[index],
what: ACTIONS[index],
when: `${(index + 1) * 7} min ago`,
}));
export function FeedRecipe() {
const [events, setEvents] = useState(INITIAL);
const unread = events.length - INITIAL.length;
const add = () =>
setEvents((list) => [
{
id: list.length,
who: PEOPLE[list.length % PEOPLE.length],
what: ACTIONS[list.length % ACTIONS.length],
when: "just now",
},
...list,
]);
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Activity</Text>
<Animate change={{ on: unread, animation: "pulse" }}>
<span className="ks-motion-count">
<Badge tone={unread ? "accent" : "neutral"}>{unread} new</Badge>
</span>
</Animate>
<Button size="sm" onClick={add} className="ks-push-end">
Simulate event
</Button>
</div>
<ul className="ks-feed-list" aria-live="polite">
{events.map((event, index) => (
<Animate
key={event.id}
// The first render's items arrive together and stagger; one added
// later enters at once. Either way, only once per item.
enter={riseAfter(event.id < INITIAL.length ? index * 0.06 : 0)}
trigger="mount"
>
<li className="ks-feed-item">
<Avatar name={event.who} size="sm" />
<Text size="sm">
<strong>{event.who}</strong> {event.what}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
{event.when}
</Text>
</li>
</Animate>
))}
</ul>
</Card>
);
}
const PLANS = [
{ value: "starter", label: "Starter", price: 0, note: "For trying Bento." },
{ value: "team", label: "Team", price: 249, note: "Up to 50 seats." },
{
value: "business",
label: "Business",
price: 799,
note: "SSO and audit logs.",
},
];
export function PlanRecipe() {
const [plan, setPlan] = useState("team");
const chosen = PLANS.find((item) => item.value === plan)!;
return (
<div className="ks-fill ks-stack">
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
<Grid min="12rem">
{PLANS.map((item) => (
<Animate key={item.value} hover="lift">
<div>
<SelectionCard
mode="single"
value={item.value}
label={item.label}
description={item.note}
>
<Text weight="strong">${item.price} / month</Text>
</SelectionCard>
</div>
</Animate>
))}
</Grid>
</RadioGroup>
<div className="ks-row-tight">
<Animate change={{ on: plan, animation: "bump" }}>
<span>
<Text>
Total today: <strong>${chosen.price}</strong>
</Text>
</span>
</Animate>
<Animate press="squish">
<Button variant="primary" className="ks-push-end">
Continue with {chosen.label}
</Button>
</Animate>
</div>
</div>
);
}
export function SaveRecipe() {
const [name, setName] = useState("");
const [attempt, setAttempt] = useState(0);
// Counts failed submits only: the shake plays per failure, never when the
// form becomes valid.
const [failures, setFailures] = useState(0);
const [saved, setSaved] = useState<string | null>(null);
const invalid = attempt > 0 && !saved && name.trim().length < 3;
return (
<Card as="div" className="ks-motion-card ks-fill">
<form
className="ks-stack"
noValidate
onSubmit={(event) => {
event.preventDefault();
const ok = name.trim().length >= 3;
setAttempt((n) => n + 1);
if (!ok) setFailures((n) => n + 1);
setSaved(ok ? name.trim() : null);
}}
>
<Animate change={{ on: failures, animation: "shake" }}>
<div>
<Field
label="Workspace name"
hint="At least three characters."
error={invalid ? "That name is too short." : undefined}
>
{(control) => (
<Input
{...control}
value={name}
onChange={(event) => {
setName(event.target.value);
setSaved(null);
}}
/>
)}
</Field>
</div>
</Animate>
<div className="ks-row-tight">
<Animate press="squish">
<Button type="submit" variant="primary">
Save
</Button>
</Animate>
</div>
{saved ? (
<Animate key={attempt} enter="rise" trigger="mount">
<div>
<Alert tone="accent" live="polite" title="Saved">
The workspace is now called “{saved}”.
</Alert>
</div>
</Animate>
) : null}
</form>
</Card>
);
}
const PROJECTS = ["atlas-api", "juniper-web", "northstar-docs", "orion-worker"];
export function LoadingRecipe() {
const [state, setState] = useState<"idle" | "loading" | "ready">("ready");
const [round, setRound] = useState(0);
const load = () => {
setState("loading");
setTimeout(() => {
setState("ready");
setRound((n) => n + 1);
}, 900);
};
return (
<Card as="div" className="ks-feed ks-fill">
<div className="ks-feed-head">
<Text weight="strong">Projects</Text>
<Button
size="sm"
onClick={load}
loading={state === "loading"}
className="ks-push-end"
>
Reload
</Button>
</div>
{state === "loading" ? (
<ul className="ks-feed-list" aria-busy="true">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Skeleton width="40%" />
</li>
))}
</ul>
) : (
<Animate
key={round}
enter={{ ...riseAfter(0), stagger: 0.06 }}
targets="li"
trigger="mount"
>
<ul className="ks-feed-list">
{PROJECTS.map((name) => (
<li key={name} className="ks-feed-item">
<Text size="sm" weight="strong">
{name}
</Text>
<Text size="sm" tone="quiet" className="ks-push-end">
deployed {(PROJECTS.indexOf(name) + 1) * 3}h ago
</Text>
</li>
))}
</ul>
</Animate>
)}
</Card>
);
}