Skip to examples
Bento / Kitchen sink
Bento / compositions

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.

Active users68
Error rate23‰
Deploys147
Seats used41

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

At least three characters.

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>
  );
}