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 users 68
Error rate 23‰
Deploys 147
Seats used 41

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.

Source src/lib/motion/doc.ts · src/lib/motion/svelte.svelte.ts · src/routes/kitchen-sink/_sections/MotionRecipes.svelte

src/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(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. 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 {};

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/routes/kitchen-sink/_sections/MotionRecipes.svelte

<script lang="ts">
	import { Sparkline } from '$lib/components/charts/sparkline';
	import { Avatar } from '$lib/components/display/avatar';
	import { Badge } from '$lib/components/display/badge';
	import { Card } from '$lib/components/display/card';
	import { Stat } from '$lib/components/display/stat';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Field } from '$lib/components/forms/field';
	import { Input } from '$lib/components/forms/input';
	import { RadioGroup } from '$lib/components/forms/radio-group';
	import { Grid } from '$lib/components/layout/grid';
	import { PageHeader } from '$lib/components/patterns/page-header';
	import { SelectionCard } from '$lib/components/patterns/selection-card';
	import { Text } from '$lib/components/typography/text';
	import type { EnterSpec } from '$lib/motion';
	import { animate } from '$lib/motion/svelte.svelte';
	import { TREND_DOWN, TREND_UP, varied } from './chart-fixtures';

	let { recipe }: { recipe: 'dashboard' | 'feed' | 'plan' | 'save' | 'loading' } = $props();

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

	// Dashboard
	const METRICS = [
		{ key: 'active', label: 'Active users', base: 68, trend: TREND_UP, unit: '' },
		{ key: 'errors', label: 'Error rate', base: 23, trend: TREND_DOWN, unit: '‰' },
		{ key: 'deploys', label: 'Deploys', base: 147, trend: TREND_UP.slice(10), unit: '' },
		{ key: 'seats', label: 'Seats used', base: 41, trend: TREND_UP.slice(4), unit: '' }
	];
	let refresh = $state(0);
	const metricValue = (index: number) =>
		refresh ? varied(refresh + index, [METRICS[index].base])[0] : METRICS[index].base;

	// Feed
	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`
	}));
	let events = $state(INITIAL);
	const unread = $derived(events.length - INITIAL.length);
	const addEvent = () =>
		(events = [
			{
				id: events.length,
				who: PEOPLE[events.length % PEOPLE.length],
				what: ACTIONS[events.length % ACTIONS.length],
				when: 'just now'
			},
			...events
		]);

	// Plan
	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.' }
	];
	let plan = $state('team');
	const chosen = $derived(PLANS.find((item) => item.value === plan) ?? PLANS[1]);

	// Save
	let name = $state('');
	let attempt = $state(0);
	// Counts failed submits only: the shake plays per failure, never when the
	// form becomes valid.
	let failures = $state(0);
	let saved = $state<string | null>(null);
	const invalid = $derived(attempt > 0 && !saved && name.trim().length < 3);

	// Loading
	const PROJECTS = ['atlas-api', 'juniper-web', 'northstar-docs', 'orion-worker'];
	let loadState = $state<'loading' | 'ready'>('ready');
	let round = $state(0);
	const load = () => {
		loadState = 'loading';
		setTimeout(() => {
			loadState = 'ready';
			round += 1;
		}, 900);
	};
</script>

{#if recipe === 'dashboard'}
	<div class="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."
		>
			{#snippet actions()}
				<Button size="sm" onclick={() => (refresh += 1)} {...animate({ press: 'squish' })}
					>Refresh</Button
				>
			{/snippet}
		</PageHeader>
		<div {...animate({ enter: { ...riseAfter(0), stagger: 0.07 }, targets: '[data-card]' })}>
			<Grid min="11rem">
				{#each METRICS as metric, index (metric.key)}
					{@const value = metricValue(index)}
					<Card as="div" class="ks-stat-card" data-card {...animate({ hover: 'lift' })}>
						<div
							class="ks-origin-start"
							{...animate({ change: { on: value, animation: 'pulse' } })}
						>
							<Stat label={metric.label} value="{value}{metric.unit}" />
						</div>
						<Sparkline
							label="{metric.label}, last 30 days"
							values={metric.trend}
							color={index === 1 ? 2 : 1}
						/>
					</Card>
				{/each}
			</Grid>
		</div>
	</div>
{:else if recipe === 'feed'}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Activity</Text>
			<span class="ks-motion-count" {...animate({ change: { on: unread, animation: 'pulse' } })}>
				<Badge tone={unread ? 'accent' : 'neutral'}>{unread} new</Badge>
			</span>
			<Button size="sm" onclick={addEvent} class="ks-push-end">Simulate event</Button>
		</div>
		<ul class="ks-feed-list" aria-live="polite">
			{#each events as event, index (event.id)}
				<!-- The first render's items arrive together and stagger; one added
				     later enters at once. Either way, only once per item. -->
				<li
					class="ks-feed-item"
					{...animate({
						enter: riseAfter(event.id < INITIAL.length ? index * 0.06 : 0),
						trigger: 'mount'
					})}
				>
					<Avatar name={event.who} size="sm" />
					<Text size="sm"><strong>{event.who}</strong> {event.what}</Text>
					<Text size="sm" tone="quiet" class="ks-push-end">{event.when}</Text>
				</li>
			{/each}
		</ul>
	</Card>
{:else if recipe === 'plan'}
	<div class="ks-fill ks-stack">
		<RadioGroup bind:value={plan} aria-label="Plan">
			<Grid min="12rem">
				{#each PLANS as item (item.value)}
					<div {...animate({ hover: 'lift' })}>
						<SelectionCard
							mode="single"
							value={item.value}
							label={item.label}
							description={item.note}
						>
							<Text weight="strong">${item.price} / month</Text>
						</SelectionCard>
					</div>
				{/each}
			</Grid>
		</RadioGroup>
		<div class="ks-row-tight">
			<span {...animate({ change: { on: plan, animation: 'bump' } })}>
				<Text>Total today: <strong>${chosen.price}</strong></Text>
			</span>
			<Button variant="primary" class="ks-push-end" {...animate({ press: 'squish' })}
				>Continue with {chosen.label}</Button
			>
		</div>
	</div>
{:else if recipe === 'save'}
	<Card as="div" class="ks-motion-card ks-fill">
		<form
			class="ks-stack"
			novalidate
			onsubmit={(event) => {
				event.preventDefault();
				const ok = name.trim().length >= 3;
				attempt += 1;
				if (!ok) failures += 1;
				saved = ok ? name.trim() : null;
			}}
		>
			<div {...animate({ change: { on: failures, animation: 'shake' } })}>
				<Field
					label="Workspace name"
					hint="At least three characters."
					error={invalid ? 'That name is too short.' : undefined}
				>
					{#snippet children(control)}
						<Input {...control} bind:value={name} oninput={() => (saved = null)} />
					{/snippet}
				</Field>
			</div>
			<div class="ks-row-tight">
				<Button type="submit" variant="primary" {...animate({ press: 'squish' })}>Save</Button>
			</div>
			{#if saved}
				{#key attempt}
					<div {...animate({ enter: 'rise', trigger: 'mount' })}>
						<Alert tone="accent" live="polite" title="Saved">
							The workspace is now called “{saved}”.
						</Alert>
					</div>
				{/key}
			{/if}
		</form>
	</Card>
{:else}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Projects</Text>
			<Button size="sm" onclick={load} loading={loadState === 'loading'} class="ks-push-end"
				>Reload</Button
			>
		</div>
		{#if loadState === 'loading'}
			<ul class="ks-feed-list" aria-busy="true">
				{#each PROJECTS as name (name)}
					<li class="ks-feed-item"><Skeleton width="40%" /></li>
				{/each}
			</ul>
		{:else}
			{#key round}
				<ul
					class="ks-feed-list"
					{...animate({
						enter: { ...riseAfter(0), stagger: 0.06 },
						targets: 'li',
						trigger: 'mount'
					})}
				>
					{#each PROJECTS as project, index (project)}
						<li class="ks-feed-item">
							<Text size="sm" weight="strong">{project}</Text>
							<Text size="sm" tone="quiet" class="ks-push-end">deployed {(index + 1) * 3}h ago</Text
							>
						</li>
					{/each}
				</ul>
			{/key}
		{/if}
	</Card>
{/if}

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.

Source src/lib/motion/doc.ts · src/lib/motion/svelte.svelte.ts · src/routes/kitchen-sink/_sections/MotionRecipes.svelte

src/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(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. 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 {};

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/routes/kitchen-sink/_sections/MotionRecipes.svelte

<script lang="ts">
	import { Sparkline } from '$lib/components/charts/sparkline';
	import { Avatar } from '$lib/components/display/avatar';
	import { Badge } from '$lib/components/display/badge';
	import { Card } from '$lib/components/display/card';
	import { Stat } from '$lib/components/display/stat';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Field } from '$lib/components/forms/field';
	import { Input } from '$lib/components/forms/input';
	import { RadioGroup } from '$lib/components/forms/radio-group';
	import { Grid } from '$lib/components/layout/grid';
	import { PageHeader } from '$lib/components/patterns/page-header';
	import { SelectionCard } from '$lib/components/patterns/selection-card';
	import { Text } from '$lib/components/typography/text';
	import type { EnterSpec } from '$lib/motion';
	import { animate } from '$lib/motion/svelte.svelte';
	import { TREND_DOWN, TREND_UP, varied } from './chart-fixtures';

	let { recipe }: { recipe: 'dashboard' | 'feed' | 'plan' | 'save' | 'loading' } = $props();

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

	// Dashboard
	const METRICS = [
		{ key: 'active', label: 'Active users', base: 68, trend: TREND_UP, unit: '' },
		{ key: 'errors', label: 'Error rate', base: 23, trend: TREND_DOWN, unit: '‰' },
		{ key: 'deploys', label: 'Deploys', base: 147, trend: TREND_UP.slice(10), unit: '' },
		{ key: 'seats', label: 'Seats used', base: 41, trend: TREND_UP.slice(4), unit: '' }
	];
	let refresh = $state(0);
	const metricValue = (index: number) =>
		refresh ? varied(refresh + index, [METRICS[index].base])[0] : METRICS[index].base;

	// Feed
	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`
	}));
	let events = $state(INITIAL);
	const unread = $derived(events.length - INITIAL.length);
	const addEvent = () =>
		(events = [
			{
				id: events.length,
				who: PEOPLE[events.length % PEOPLE.length],
				what: ACTIONS[events.length % ACTIONS.length],
				when: 'just now'
			},
			...events
		]);

	// Plan
	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.' }
	];
	let plan = $state('team');
	const chosen = $derived(PLANS.find((item) => item.value === plan) ?? PLANS[1]);

	// Save
	let name = $state('');
	let attempt = $state(0);
	// Counts failed submits only: the shake plays per failure, never when the
	// form becomes valid.
	let failures = $state(0);
	let saved = $state<string | null>(null);
	const invalid = $derived(attempt > 0 && !saved && name.trim().length < 3);

	// Loading
	const PROJECTS = ['atlas-api', 'juniper-web', 'northstar-docs', 'orion-worker'];
	let loadState = $state<'loading' | 'ready'>('ready');
	let round = $state(0);
	const load = () => {
		loadState = 'loading';
		setTimeout(() => {
			loadState = 'ready';
			round += 1;
		}, 900);
	};
</script>

{#if recipe === 'dashboard'}
	<div class="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."
		>
			{#snippet actions()}
				<Button size="sm" onclick={() => (refresh += 1)} {...animate({ press: 'squish' })}
					>Refresh</Button
				>
			{/snippet}
		</PageHeader>
		<div {...animate({ enter: { ...riseAfter(0), stagger: 0.07 }, targets: '[data-card]' })}>
			<Grid min="11rem">
				{#each METRICS as metric, index (metric.key)}
					{@const value = metricValue(index)}
					<Card as="div" class="ks-stat-card" data-card {...animate({ hover: 'lift' })}>
						<div
							class="ks-origin-start"
							{...animate({ change: { on: value, animation: 'pulse' } })}
						>
							<Stat label={metric.label} value="{value}{metric.unit}" />
						</div>
						<Sparkline
							label="{metric.label}, last 30 days"
							values={metric.trend}
							color={index === 1 ? 2 : 1}
						/>
					</Card>
				{/each}
			</Grid>
		</div>
	</div>
{:else if recipe === 'feed'}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Activity</Text>
			<span class="ks-motion-count" {...animate({ change: { on: unread, animation: 'pulse' } })}>
				<Badge tone={unread ? 'accent' : 'neutral'}>{unread} new</Badge>
			</span>
			<Button size="sm" onclick={addEvent} class="ks-push-end">Simulate event</Button>
		</div>
		<ul class="ks-feed-list" aria-live="polite">
			{#each events as event, index (event.id)}
				<!-- The first render's items arrive together and stagger; one added
				     later enters at once. Either way, only once per item. -->
				<li
					class="ks-feed-item"
					{...animate({
						enter: riseAfter(event.id < INITIAL.length ? index * 0.06 : 0),
						trigger: 'mount'
					})}
				>
					<Avatar name={event.who} size="sm" />
					<Text size="sm"><strong>{event.who}</strong> {event.what}</Text>
					<Text size="sm" tone="quiet" class="ks-push-end">{event.when}</Text>
				</li>
			{/each}
		</ul>
	</Card>
{:else if recipe === 'plan'}
	<div class="ks-fill ks-stack">
		<RadioGroup bind:value={plan} aria-label="Plan">
			<Grid min="12rem">
				{#each PLANS as item (item.value)}
					<div {...animate({ hover: 'lift' })}>
						<SelectionCard
							mode="single"
							value={item.value}
							label={item.label}
							description={item.note}
						>
							<Text weight="strong">${item.price} / month</Text>
						</SelectionCard>
					</div>
				{/each}
			</Grid>
		</RadioGroup>
		<div class="ks-row-tight">
			<span {...animate({ change: { on: plan, animation: 'bump' } })}>
				<Text>Total today: <strong>${chosen.price}</strong></Text>
			</span>
			<Button variant="primary" class="ks-push-end" {...animate({ press: 'squish' })}
				>Continue with {chosen.label}</Button
			>
		</div>
	</div>
{:else if recipe === 'save'}
	<Card as="div" class="ks-motion-card ks-fill">
		<form
			class="ks-stack"
			novalidate
			onsubmit={(event) => {
				event.preventDefault();
				const ok = name.trim().length >= 3;
				attempt += 1;
				if (!ok) failures += 1;
				saved = ok ? name.trim() : null;
			}}
		>
			<div {...animate({ change: { on: failures, animation: 'shake' } })}>
				<Field
					label="Workspace name"
					hint="At least three characters."
					error={invalid ? 'That name is too short.' : undefined}
				>
					{#snippet children(control)}
						<Input {...control} bind:value={name} oninput={() => (saved = null)} />
					{/snippet}
				</Field>
			</div>
			<div class="ks-row-tight">
				<Button type="submit" variant="primary" {...animate({ press: 'squish' })}>Save</Button>
			</div>
			{#if saved}
				{#key attempt}
					<div {...animate({ enter: 'rise', trigger: 'mount' })}>
						<Alert tone="accent" live="polite" title="Saved">
							The workspace is now called “{saved}”.
						</Alert>
					</div>
				{/key}
			{/if}
		</form>
	</Card>
{:else}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Projects</Text>
			<Button size="sm" onclick={load} loading={loadState === 'loading'} class="ks-push-end"
				>Reload</Button
			>
		</div>
		{#if loadState === 'loading'}
			<ul class="ks-feed-list" aria-busy="true">
				{#each PROJECTS as name (name)}
					<li class="ks-feed-item"><Skeleton width="40%" /></li>
				{/each}
			</ul>
		{:else}
			{#key round}
				<ul
					class="ks-feed-list"
					{...animate({
						enter: { ...riseAfter(0), stagger: 0.06 },
						targets: 'li',
						trigger: 'mount'
					})}
				>
					{#each PROJECTS as project, index (project)}
						<li class="ks-feed-item">
							<Text size="sm" weight="strong">{project}</Text>
							<Text size="sm" tone="quiet" class="ks-push-end">deployed {(index + 1) * 3}h ago</Text
							>
						</li>
					{/each}
				</ul>
			{/key}
		{/if}
	</Card>
{/if}

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

Source src/lib/motion/doc.ts · src/lib/motion/svelte.svelte.ts · src/routes/kitchen-sink/_sections/MotionRecipes.svelte

src/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(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. 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 {};

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/routes/kitchen-sink/_sections/MotionRecipes.svelte

<script lang="ts">
	import { Sparkline } from '$lib/components/charts/sparkline';
	import { Avatar } from '$lib/components/display/avatar';
	import { Badge } from '$lib/components/display/badge';
	import { Card } from '$lib/components/display/card';
	import { Stat } from '$lib/components/display/stat';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Field } from '$lib/components/forms/field';
	import { Input } from '$lib/components/forms/input';
	import { RadioGroup } from '$lib/components/forms/radio-group';
	import { Grid } from '$lib/components/layout/grid';
	import { PageHeader } from '$lib/components/patterns/page-header';
	import { SelectionCard } from '$lib/components/patterns/selection-card';
	import { Text } from '$lib/components/typography/text';
	import type { EnterSpec } from '$lib/motion';
	import { animate } from '$lib/motion/svelte.svelte';
	import { TREND_DOWN, TREND_UP, varied } from './chart-fixtures';

	let { recipe }: { recipe: 'dashboard' | 'feed' | 'plan' | 'save' | 'loading' } = $props();

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

	// Dashboard
	const METRICS = [
		{ key: 'active', label: 'Active users', base: 68, trend: TREND_UP, unit: '' },
		{ key: 'errors', label: 'Error rate', base: 23, trend: TREND_DOWN, unit: '‰' },
		{ key: 'deploys', label: 'Deploys', base: 147, trend: TREND_UP.slice(10), unit: '' },
		{ key: 'seats', label: 'Seats used', base: 41, trend: TREND_UP.slice(4), unit: '' }
	];
	let refresh = $state(0);
	const metricValue = (index: number) =>
		refresh ? varied(refresh + index, [METRICS[index].base])[0] : METRICS[index].base;

	// Feed
	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`
	}));
	let events = $state(INITIAL);
	const unread = $derived(events.length - INITIAL.length);
	const addEvent = () =>
		(events = [
			{
				id: events.length,
				who: PEOPLE[events.length % PEOPLE.length],
				what: ACTIONS[events.length % ACTIONS.length],
				when: 'just now'
			},
			...events
		]);

	// Plan
	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.' }
	];
	let plan = $state('team');
	const chosen = $derived(PLANS.find((item) => item.value === plan) ?? PLANS[1]);

	// Save
	let name = $state('');
	let attempt = $state(0);
	// Counts failed submits only: the shake plays per failure, never when the
	// form becomes valid.
	let failures = $state(0);
	let saved = $state<string | null>(null);
	const invalid = $derived(attempt > 0 && !saved && name.trim().length < 3);

	// Loading
	const PROJECTS = ['atlas-api', 'juniper-web', 'northstar-docs', 'orion-worker'];
	let loadState = $state<'loading' | 'ready'>('ready');
	let round = $state(0);
	const load = () => {
		loadState = 'loading';
		setTimeout(() => {
			loadState = 'ready';
			round += 1;
		}, 900);
	};
</script>

{#if recipe === 'dashboard'}
	<div class="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."
		>
			{#snippet actions()}
				<Button size="sm" onclick={() => (refresh += 1)} {...animate({ press: 'squish' })}
					>Refresh</Button
				>
			{/snippet}
		</PageHeader>
		<div {...animate({ enter: { ...riseAfter(0), stagger: 0.07 }, targets: '[data-card]' })}>
			<Grid min="11rem">
				{#each METRICS as metric, index (metric.key)}
					{@const value = metricValue(index)}
					<Card as="div" class="ks-stat-card" data-card {...animate({ hover: 'lift' })}>
						<div
							class="ks-origin-start"
							{...animate({ change: { on: value, animation: 'pulse' } })}
						>
							<Stat label={metric.label} value="{value}{metric.unit}" />
						</div>
						<Sparkline
							label="{metric.label}, last 30 days"
							values={metric.trend}
							color={index === 1 ? 2 : 1}
						/>
					</Card>
				{/each}
			</Grid>
		</div>
	</div>
{:else if recipe === 'feed'}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Activity</Text>
			<span class="ks-motion-count" {...animate({ change: { on: unread, animation: 'pulse' } })}>
				<Badge tone={unread ? 'accent' : 'neutral'}>{unread} new</Badge>
			</span>
			<Button size="sm" onclick={addEvent} class="ks-push-end">Simulate event</Button>
		</div>
		<ul class="ks-feed-list" aria-live="polite">
			{#each events as event, index (event.id)}
				<!-- The first render's items arrive together and stagger; one added
				     later enters at once. Either way, only once per item. -->
				<li
					class="ks-feed-item"
					{...animate({
						enter: riseAfter(event.id < INITIAL.length ? index * 0.06 : 0),
						trigger: 'mount'
					})}
				>
					<Avatar name={event.who} size="sm" />
					<Text size="sm"><strong>{event.who}</strong> {event.what}</Text>
					<Text size="sm" tone="quiet" class="ks-push-end">{event.when}</Text>
				</li>
			{/each}
		</ul>
	</Card>
{:else if recipe === 'plan'}
	<div class="ks-fill ks-stack">
		<RadioGroup bind:value={plan} aria-label="Plan">
			<Grid min="12rem">
				{#each PLANS as item (item.value)}
					<div {...animate({ hover: 'lift' })}>
						<SelectionCard
							mode="single"
							value={item.value}
							label={item.label}
							description={item.note}
						>
							<Text weight="strong">${item.price} / month</Text>
						</SelectionCard>
					</div>
				{/each}
			</Grid>
		</RadioGroup>
		<div class="ks-row-tight">
			<span {...animate({ change: { on: plan, animation: 'bump' } })}>
				<Text>Total today: <strong>${chosen.price}</strong></Text>
			</span>
			<Button variant="primary" class="ks-push-end" {...animate({ press: 'squish' })}
				>Continue with {chosen.label}</Button
			>
		</div>
	</div>
{:else if recipe === 'save'}
	<Card as="div" class="ks-motion-card ks-fill">
		<form
			class="ks-stack"
			novalidate
			onsubmit={(event) => {
				event.preventDefault();
				const ok = name.trim().length >= 3;
				attempt += 1;
				if (!ok) failures += 1;
				saved = ok ? name.trim() : null;
			}}
		>
			<div {...animate({ change: { on: failures, animation: 'shake' } })}>
				<Field
					label="Workspace name"
					hint="At least three characters."
					error={invalid ? 'That name is too short.' : undefined}
				>
					{#snippet children(control)}
						<Input {...control} bind:value={name} oninput={() => (saved = null)} />
					{/snippet}
				</Field>
			</div>
			<div class="ks-row-tight">
				<Button type="submit" variant="primary" {...animate({ press: 'squish' })}>Save</Button>
			</div>
			{#if saved}
				{#key attempt}
					<div {...animate({ enter: 'rise', trigger: 'mount' })}>
						<Alert tone="accent" live="polite" title="Saved">
							The workspace is now called “{saved}”.
						</Alert>
					</div>
				{/key}
			{/if}
		</form>
	</Card>
{:else}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Projects</Text>
			<Button size="sm" onclick={load} loading={loadState === 'loading'} class="ks-push-end"
				>Reload</Button
			>
		</div>
		{#if loadState === 'loading'}
			<ul class="ks-feed-list" aria-busy="true">
				{#each PROJECTS as name (name)}
					<li class="ks-feed-item"><Skeleton width="40%" /></li>
				{/each}
			</ul>
		{:else}
			{#key round}
				<ul
					class="ks-feed-list"
					{...animate({
						enter: { ...riseAfter(0), stagger: 0.06 },
						targets: 'li',
						trigger: 'mount'
					})}
				>
					{#each PROJECTS as project, index (project)}
						<li class="ks-feed-item">
							<Text size="sm" weight="strong">{project}</Text>
							<Text size="sm" tone="quiet" class="ks-push-end">deployed {(index + 1) * 3}h ago</Text
							>
						</li>
					{/each}
				</ul>
			{/key}
		{/if}
	</Card>
{/if}

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.

Source src/lib/motion/doc.ts · src/lib/motion/svelte.svelte.ts · src/routes/kitchen-sink/_sections/MotionRecipes.svelte

src/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(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. 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 {};

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/routes/kitchen-sink/_sections/MotionRecipes.svelte

<script lang="ts">
	import { Sparkline } from '$lib/components/charts/sparkline';
	import { Avatar } from '$lib/components/display/avatar';
	import { Badge } from '$lib/components/display/badge';
	import { Card } from '$lib/components/display/card';
	import { Stat } from '$lib/components/display/stat';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Field } from '$lib/components/forms/field';
	import { Input } from '$lib/components/forms/input';
	import { RadioGroup } from '$lib/components/forms/radio-group';
	import { Grid } from '$lib/components/layout/grid';
	import { PageHeader } from '$lib/components/patterns/page-header';
	import { SelectionCard } from '$lib/components/patterns/selection-card';
	import { Text } from '$lib/components/typography/text';
	import type { EnterSpec } from '$lib/motion';
	import { animate } from '$lib/motion/svelte.svelte';
	import { TREND_DOWN, TREND_UP, varied } from './chart-fixtures';

	let { recipe }: { recipe: 'dashboard' | 'feed' | 'plan' | 'save' | 'loading' } = $props();

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

	// Dashboard
	const METRICS = [
		{ key: 'active', label: 'Active users', base: 68, trend: TREND_UP, unit: '' },
		{ key: 'errors', label: 'Error rate', base: 23, trend: TREND_DOWN, unit: '‰' },
		{ key: 'deploys', label: 'Deploys', base: 147, trend: TREND_UP.slice(10), unit: '' },
		{ key: 'seats', label: 'Seats used', base: 41, trend: TREND_UP.slice(4), unit: '' }
	];
	let refresh = $state(0);
	const metricValue = (index: number) =>
		refresh ? varied(refresh + index, [METRICS[index].base])[0] : METRICS[index].base;

	// Feed
	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`
	}));
	let events = $state(INITIAL);
	const unread = $derived(events.length - INITIAL.length);
	const addEvent = () =>
		(events = [
			{
				id: events.length,
				who: PEOPLE[events.length % PEOPLE.length],
				what: ACTIONS[events.length % ACTIONS.length],
				when: 'just now'
			},
			...events
		]);

	// Plan
	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.' }
	];
	let plan = $state('team');
	const chosen = $derived(PLANS.find((item) => item.value === plan) ?? PLANS[1]);

	// Save
	let name = $state('');
	let attempt = $state(0);
	// Counts failed submits only: the shake plays per failure, never when the
	// form becomes valid.
	let failures = $state(0);
	let saved = $state<string | null>(null);
	const invalid = $derived(attempt > 0 && !saved && name.trim().length < 3);

	// Loading
	const PROJECTS = ['atlas-api', 'juniper-web', 'northstar-docs', 'orion-worker'];
	let loadState = $state<'loading' | 'ready'>('ready');
	let round = $state(0);
	const load = () => {
		loadState = 'loading';
		setTimeout(() => {
			loadState = 'ready';
			round += 1;
		}, 900);
	};
</script>

{#if recipe === 'dashboard'}
	<div class="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."
		>
			{#snippet actions()}
				<Button size="sm" onclick={() => (refresh += 1)} {...animate({ press: 'squish' })}
					>Refresh</Button
				>
			{/snippet}
		</PageHeader>
		<div {...animate({ enter: { ...riseAfter(0), stagger: 0.07 }, targets: '[data-card]' })}>
			<Grid min="11rem">
				{#each METRICS as metric, index (metric.key)}
					{@const value = metricValue(index)}
					<Card as="div" class="ks-stat-card" data-card {...animate({ hover: 'lift' })}>
						<div
							class="ks-origin-start"
							{...animate({ change: { on: value, animation: 'pulse' } })}
						>
							<Stat label={metric.label} value="{value}{metric.unit}" />
						</div>
						<Sparkline
							label="{metric.label}, last 30 days"
							values={metric.trend}
							color={index === 1 ? 2 : 1}
						/>
					</Card>
				{/each}
			</Grid>
		</div>
	</div>
{:else if recipe === 'feed'}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Activity</Text>
			<span class="ks-motion-count" {...animate({ change: { on: unread, animation: 'pulse' } })}>
				<Badge tone={unread ? 'accent' : 'neutral'}>{unread} new</Badge>
			</span>
			<Button size="sm" onclick={addEvent} class="ks-push-end">Simulate event</Button>
		</div>
		<ul class="ks-feed-list" aria-live="polite">
			{#each events as event, index (event.id)}
				<!-- The first render's items arrive together and stagger; one added
				     later enters at once. Either way, only once per item. -->
				<li
					class="ks-feed-item"
					{...animate({
						enter: riseAfter(event.id < INITIAL.length ? index * 0.06 : 0),
						trigger: 'mount'
					})}
				>
					<Avatar name={event.who} size="sm" />
					<Text size="sm"><strong>{event.who}</strong> {event.what}</Text>
					<Text size="sm" tone="quiet" class="ks-push-end">{event.when}</Text>
				</li>
			{/each}
		</ul>
	</Card>
{:else if recipe === 'plan'}
	<div class="ks-fill ks-stack">
		<RadioGroup bind:value={plan} aria-label="Plan">
			<Grid min="12rem">
				{#each PLANS as item (item.value)}
					<div {...animate({ hover: 'lift' })}>
						<SelectionCard
							mode="single"
							value={item.value}
							label={item.label}
							description={item.note}
						>
							<Text weight="strong">${item.price} / month</Text>
						</SelectionCard>
					</div>
				{/each}
			</Grid>
		</RadioGroup>
		<div class="ks-row-tight">
			<span {...animate({ change: { on: plan, animation: 'bump' } })}>
				<Text>Total today: <strong>${chosen.price}</strong></Text>
			</span>
			<Button variant="primary" class="ks-push-end" {...animate({ press: 'squish' })}
				>Continue with {chosen.label}</Button
			>
		</div>
	</div>
{:else if recipe === 'save'}
	<Card as="div" class="ks-motion-card ks-fill">
		<form
			class="ks-stack"
			novalidate
			onsubmit={(event) => {
				event.preventDefault();
				const ok = name.trim().length >= 3;
				attempt += 1;
				if (!ok) failures += 1;
				saved = ok ? name.trim() : null;
			}}
		>
			<div {...animate({ change: { on: failures, animation: 'shake' } })}>
				<Field
					label="Workspace name"
					hint="At least three characters."
					error={invalid ? 'That name is too short.' : undefined}
				>
					{#snippet children(control)}
						<Input {...control} bind:value={name} oninput={() => (saved = null)} />
					{/snippet}
				</Field>
			</div>
			<div class="ks-row-tight">
				<Button type="submit" variant="primary" {...animate({ press: 'squish' })}>Save</Button>
			</div>
			{#if saved}
				{#key attempt}
					<div {...animate({ enter: 'rise', trigger: 'mount' })}>
						<Alert tone="accent" live="polite" title="Saved">
							The workspace is now called “{saved}”.
						</Alert>
					</div>
				{/key}
			{/if}
		</form>
	</Card>
{:else}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Projects</Text>
			<Button size="sm" onclick={load} loading={loadState === 'loading'} class="ks-push-end"
				>Reload</Button
			>
		</div>
		{#if loadState === 'loading'}
			<ul class="ks-feed-list" aria-busy="true">
				{#each PROJECTS as name (name)}
					<li class="ks-feed-item"><Skeleton width="40%" /></li>
				{/each}
			</ul>
		{:else}
			{#key round}
				<ul
					class="ks-feed-list"
					{...animate({
						enter: { ...riseAfter(0), stagger: 0.06 },
						targets: 'li',
						trigger: 'mount'
					})}
				>
					{#each PROJECTS as project, index (project)}
						<li class="ks-feed-item">
							<Text size="sm" weight="strong">{project}</Text>
							<Text size="sm" tone="quiet" class="ks-push-end">deployed {(index + 1) * 3}h ago</Text
							>
						</li>
					{/each}
				</ul>
			{/key}
		{/if}
	</Card>
{/if}

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

Source src/lib/motion/doc.ts · src/lib/motion/svelte.svelte.ts · src/routes/kitchen-sink/_sections/MotionRecipes.svelte

src/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(). svelte.svelte.ts
 * (react.ts in the Next app) only wires those to a component's lifecycle: an
 * attachment for the entrance, a Tweened class for updates.
 *
 * The helper: lib/motion/svelte.svelte.ts: animate(options) returns an object to spread —
 * the pending attribute plus an attachment keyed with createAttachmentKey(),
 * so it passes through components that forward rest props. 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 {};

src/lib/motion/svelte.svelte.ts

import { untrack } from 'svelte';
import { createAttachmentKey, type Attachment } from 'svelte/attachments';

import {
	bindGestures,
	enterOnce,
	PENDING,
	playChange,
	runEnter,
	tweenRecord,
	type Fresh,
	type NumberRecord
} from './run';
import {
	enterSpec,
	resolveAnimation,
	type AnimateOptions,
	type AnimationProp,
	type EnterPreset,
	type EnterSpec,
	type GrowAxis,
	type Timing
} from './specs';

/** An attachment that runs an entrance on the element it is attached to.
 *  Remount (a keyed block) to replay it. */
export function enter(spec: EnterSpec | null, trigger: 'mount' | 'visible'): Attachment<Element> {
	return (node) => runEnter(node, spec, trigger);
}

/** A record of numbers that moves to each new target over `timing`. Starts at
 *  the first target, so server and client agree; a new target mid-tween
 *  starts from wherever the last had got to. Construct during component
 *  initialisation. */
export class Tweened {
	current = $state.raw<NumberRecord>({});
	#stop = () => {};

	constructor(target: () => NumberRecord, timing: () => Timing | null, fresh: Fresh = 'zero') {
		const initial = target();
		this.current = initial;
		let last = JSON.stringify(initial);

		$effect(() => {
			const to = target();
			const key = JSON.stringify(to);
			if (key === last) return;
			last = key;
			const how = timing();
			const from = untrack(() => this.current);
			this.#stop();
			this.#stop = tweenRecord(from, to, how, (value) => (this.current = value), fresh);
		});
		$effect(() => () => this.#stop());
	}
}

/** A chart's motion wiring: its resolved animation, the attachment for its
 *  root, and the attribute that hides its marks until the entrance runs. */
export function chartMotion(
	animation: () => AnimationProp | undefined,
	defaults: { enter: EnterPreset; axis: GrowAxis }
) {
	const resolved = $derived(resolveAnimation(animation(), defaults));
	return {
		get update() {
			return resolved.update;
		},
		get attach() {
			return enter(resolved.enter, resolved.trigger);
		},
		get pending() {
			return { [PENDING]: resolved.enter ? '' : undefined };
		}
	};
}

/** The last `change.on` seen per element, so only a real change plays. */
const seen = new WeakMap<Element, { on: unknown }>();

/**
 * Animate any element: an entrance, hover and press states, and a flourish
 * when a value changes. Spread the result on an element, or on a component
 * that forwards its rest props:
 *
 *     <Card {...animate({ enter: 'rise', hover: 'lift' })}>
 *
 * It carries the pre-entrance attribute (rendered on the server too, so
 * nothing flashes) and an attachment. The attachment re-runs when the options
 * change, but an entrance plays once per element; `change.on` is compared by
 * identity, so pass a primitive.
 */
export function animate(options: AnimateOptions) {
	const attachment: Attachment<Element> = (element) => {
		const cleanups = [
			enterOnce(
				element,
				enterSpec(options.enter),
				options.trigger ?? 'visible',
				options.targets ?? null
			),
			bindGestures(element, options.hover, options.press)
		];
		if (options.change) {
			const previous = seen.get(element);
			seen.set(element, { on: options.change.on });
			if (previous && !Object.is(previous.on, options.change.on))
				cleanups.push(playChange(element, options.change.animation));
		}
		return () => cleanups.forEach((cleanup) => cleanup());
	};
	return {
		[PENDING]: options.enter ? 'enter' : undefined,
		[createAttachmentKey()]: attachment
	};
}

src/routes/kitchen-sink/_sections/MotionRecipes.svelte

<script lang="ts">
	import { Sparkline } from '$lib/components/charts/sparkline';
	import { Avatar } from '$lib/components/display/avatar';
	import { Badge } from '$lib/components/display/badge';
	import { Card } from '$lib/components/display/card';
	import { Stat } from '$lib/components/display/stat';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Field } from '$lib/components/forms/field';
	import { Input } from '$lib/components/forms/input';
	import { RadioGroup } from '$lib/components/forms/radio-group';
	import { Grid } from '$lib/components/layout/grid';
	import { PageHeader } from '$lib/components/patterns/page-header';
	import { SelectionCard } from '$lib/components/patterns/selection-card';
	import { Text } from '$lib/components/typography/text';
	import type { EnterSpec } from '$lib/motion';
	import { animate } from '$lib/motion/svelte.svelte';
	import { TREND_DOWN, TREND_UP, varied } from './chart-fixtures';

	let { recipe }: { recipe: 'dashboard' | 'feed' | 'plan' | 'save' | 'loading' } = $props();

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

	// Dashboard
	const METRICS = [
		{ key: 'active', label: 'Active users', base: 68, trend: TREND_UP, unit: '' },
		{ key: 'errors', label: 'Error rate', base: 23, trend: TREND_DOWN, unit: '‰' },
		{ key: 'deploys', label: 'Deploys', base: 147, trend: TREND_UP.slice(10), unit: '' },
		{ key: 'seats', label: 'Seats used', base: 41, trend: TREND_UP.slice(4), unit: '' }
	];
	let refresh = $state(0);
	const metricValue = (index: number) =>
		refresh ? varied(refresh + index, [METRICS[index].base])[0] : METRICS[index].base;

	// Feed
	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`
	}));
	let events = $state(INITIAL);
	const unread = $derived(events.length - INITIAL.length);
	const addEvent = () =>
		(events = [
			{
				id: events.length,
				who: PEOPLE[events.length % PEOPLE.length],
				what: ACTIONS[events.length % ACTIONS.length],
				when: 'just now'
			},
			...events
		]);

	// Plan
	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.' }
	];
	let plan = $state('team');
	const chosen = $derived(PLANS.find((item) => item.value === plan) ?? PLANS[1]);

	// Save
	let name = $state('');
	let attempt = $state(0);
	// Counts failed submits only: the shake plays per failure, never when the
	// form becomes valid.
	let failures = $state(0);
	let saved = $state<string | null>(null);
	const invalid = $derived(attempt > 0 && !saved && name.trim().length < 3);

	// Loading
	const PROJECTS = ['atlas-api', 'juniper-web', 'northstar-docs', 'orion-worker'];
	let loadState = $state<'loading' | 'ready'>('ready');
	let round = $state(0);
	const load = () => {
		loadState = 'loading';
		setTimeout(() => {
			loadState = 'ready';
			round += 1;
		}, 900);
	};
</script>

{#if recipe === 'dashboard'}
	<div class="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."
		>
			{#snippet actions()}
				<Button size="sm" onclick={() => (refresh += 1)} {...animate({ press: 'squish' })}
					>Refresh</Button
				>
			{/snippet}
		</PageHeader>
		<div {...animate({ enter: { ...riseAfter(0), stagger: 0.07 }, targets: '[data-card]' })}>
			<Grid min="11rem">
				{#each METRICS as metric, index (metric.key)}
					{@const value = metricValue(index)}
					<Card as="div" class="ks-stat-card" data-card {...animate({ hover: 'lift' })}>
						<div
							class="ks-origin-start"
							{...animate({ change: { on: value, animation: 'pulse' } })}
						>
							<Stat label={metric.label} value="{value}{metric.unit}" />
						</div>
						<Sparkline
							label="{metric.label}, last 30 days"
							values={metric.trend}
							color={index === 1 ? 2 : 1}
						/>
					</Card>
				{/each}
			</Grid>
		</div>
	</div>
{:else if recipe === 'feed'}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Activity</Text>
			<span class="ks-motion-count" {...animate({ change: { on: unread, animation: 'pulse' } })}>
				<Badge tone={unread ? 'accent' : 'neutral'}>{unread} new</Badge>
			</span>
			<Button size="sm" onclick={addEvent} class="ks-push-end">Simulate event</Button>
		</div>
		<ul class="ks-feed-list" aria-live="polite">
			{#each events as event, index (event.id)}
				<!-- The first render's items arrive together and stagger; one added
				     later enters at once. Either way, only once per item. -->
				<li
					class="ks-feed-item"
					{...animate({
						enter: riseAfter(event.id < INITIAL.length ? index * 0.06 : 0),
						trigger: 'mount'
					})}
				>
					<Avatar name={event.who} size="sm" />
					<Text size="sm"><strong>{event.who}</strong> {event.what}</Text>
					<Text size="sm" tone="quiet" class="ks-push-end">{event.when}</Text>
				</li>
			{/each}
		</ul>
	</Card>
{:else if recipe === 'plan'}
	<div class="ks-fill ks-stack">
		<RadioGroup bind:value={plan} aria-label="Plan">
			<Grid min="12rem">
				{#each PLANS as item (item.value)}
					<div {...animate({ hover: 'lift' })}>
						<SelectionCard
							mode="single"
							value={item.value}
							label={item.label}
							description={item.note}
						>
							<Text weight="strong">${item.price} / month</Text>
						</SelectionCard>
					</div>
				{/each}
			</Grid>
		</RadioGroup>
		<div class="ks-row-tight">
			<span {...animate({ change: { on: plan, animation: 'bump' } })}>
				<Text>Total today: <strong>${chosen.price}</strong></Text>
			</span>
			<Button variant="primary" class="ks-push-end" {...animate({ press: 'squish' })}
				>Continue with {chosen.label}</Button
			>
		</div>
	</div>
{:else if recipe === 'save'}
	<Card as="div" class="ks-motion-card ks-fill">
		<form
			class="ks-stack"
			novalidate
			onsubmit={(event) => {
				event.preventDefault();
				const ok = name.trim().length >= 3;
				attempt += 1;
				if (!ok) failures += 1;
				saved = ok ? name.trim() : null;
			}}
		>
			<div {...animate({ change: { on: failures, animation: 'shake' } })}>
				<Field
					label="Workspace name"
					hint="At least three characters."
					error={invalid ? 'That name is too short.' : undefined}
				>
					{#snippet children(control)}
						<Input {...control} bind:value={name} oninput={() => (saved = null)} />
					{/snippet}
				</Field>
			</div>
			<div class="ks-row-tight">
				<Button type="submit" variant="primary" {...animate({ press: 'squish' })}>Save</Button>
			</div>
			{#if saved}
				{#key attempt}
					<div {...animate({ enter: 'rise', trigger: 'mount' })}>
						<Alert tone="accent" live="polite" title="Saved">
							The workspace is now called “{saved}”.
						</Alert>
					</div>
				{/key}
			{/if}
		</form>
	</Card>
{:else}
	<Card as="div" class="ks-feed ks-fill">
		<div class="ks-feed-head">
			<Text weight="strong">Projects</Text>
			<Button size="sm" onclick={load} loading={loadState === 'loading'} class="ks-push-end"
				>Reload</Button
			>
		</div>
		{#if loadState === 'loading'}
			<ul class="ks-feed-list" aria-busy="true">
				{#each PROJECTS as name (name)}
					<li class="ks-feed-item"><Skeleton width="40%" /></li>
				{/each}
			</ul>
		{:else}
			{#key round}
				<ul
					class="ks-feed-list"
					{...animate({
						enter: { ...riseAfter(0), stagger: 0.06 },
						targets: 'li',
						trigger: 'mount'
					})}
				>
					{#each PROJECTS as project, index (project)}
						<li class="ks-feed-item">
							<Text size="sm" weight="strong">{project}</Text>
							<Text size="sm" tone="quiet" class="ks-push-end">deployed {(index + 1) * 3}h ago</Text
							>
						</li>
					{/each}
				</ul>
			{/key}
		{/if}
	</Card>
{/if}