Actions storyDesign system
VisualSite

Actions story

Dot animation of browser actions and the resulting page.

npx context-ds add story-actions
On the site

Usage

Use it for

Rendering dynamic pages (clicks, waits, scrolls) on a site card. Plays only on screen; shows the rendered page with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

examples/story-actions.tsx
import { Card } from "@/components/ui/card";import { ActionsStory } from "@/components/visuals/stories/actions-story";export function Site() {  return (    <Card variant="ring" className="min-h-[22rem] w-full max-w-xl items-center justify-center">      <div className="relative w-full max-w-[26rem]">        <ActionsStory />      </div>    </Card>  );}

Source

components/visuals/stories/actions-story.tsx
'use client';import { ACTIONS_RESTING, ACTIONS_STORY_GRID } from '@/components/visuals/stories/actions-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadActionsStory: LoadDotStory = async (stage) => {	const { createActionsStoryPlayer } = await import('@/components/visuals/stories/actions-story-player');	return createActionsStoryPlayer(stage);};/** * The "Read dynamic pages" card's picture: a pointer clicks, an hourglass waits and a mouse scrolls, then the page * they leave behind renders. With reduced motion it shows the rendered page. */export function ActionsStory({ className }: { className?: string }) {	return <DotStory grid={ACTIONS_STORY_GRID} resting={ACTIONS_RESTING} load={loadActionsStory} className={className} />;}
components/visuals/stories/actions-story-player.ts
import { ACTIONS_STORY_GRID, CAPTIONS, HOURGLASS_AT_REST, HOURGLASS_RUN_OUT, MOUSE_AT_REST, PAGE_LOADING, PAGE_RENDERED, POINTER_AT_REST, paintHourglass, paintMouse, paintPointer, renderingOrder } from '@/components/visuals/stories/actions-story-scenes';import { createBeatStoryPlayer, typingSeconds, type StoryBeat, type StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { clamp01, easeOut } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage, DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * One scrape of a dynamic page, played as a loop: a pointer clicks, an hourglass runs while the content loads, a * mouse scrolls to the end, each melting into the next with its action typed beside it; then the page itself * arrives, still loading, and renders from the top down. */const MELT_SECONDS = 1.1;const TYPING = { startsAt: 0.15, charactersPerSecond: 16 };const DETAIL_FADE_SECONDS = 0.3;const CLICKS = { startAt: [1, 1.5], seconds: 0.45 };const SAND = { startsAt: 0.3, seconds: 2 };const ROLL = { startsAt: 0.3, seconds: 0.7, times: 3 };const RENDER_SECONDS = 1.3;const clickedAt = (heldSeconds: number) => {	const startedAt = CLICKS.startAt.findLast((at) => heldSeconds >= at);	return startedAt === undefined ? 0 : easeOut(clamp01((heldSeconds - startedAt) / CLICKS.seconds));};// The wheel comes back to the top of its slot after its last roll, where the picture that melts away has it.const rolledAt = (heldSeconds: number) => {	const rolls = (heldSeconds - ROLL.startsAt) / ROLL.seconds;	return rolls < 0 || rolls >= ROLL.times ? 0 : easeOut(rolls % 1);};function actionBeat(picture: StoryPicture, [step, action, detail]: DotStoryText[], holdSeconds: number, animate?: StoryBeat['animate']): StoryBeat {	const typedAt = TYPING.startsAt + typingSeconds(action, TYPING.charactersPerSecond);	return {		picture,		arrival: { kind: 'melts', seconds: MELT_SECONDS },		holdSeconds,		animate,		write(pen, heldSeconds) {			pen.text(step);			pen.type(action, heldSeconds, TYPING);			pen.text(detail, clamp01((heldSeconds - typedAt) / DETAIL_FADE_SECONDS));		},	};}export function createActionsStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: ACTIONS_STORY_GRID,		resting: { beat: 4, heldSeconds: 2.6 },		beats: [			actionBeat(POINTER_AT_REST, CAPTIONS.click, 2.3, (states, heldSeconds) => void paintPointer(states, clickedAt(heldSeconds))),			{ ...actionBeat(HOURGLASS_AT_REST, CAPTIONS.wait, SAND.startsAt + SAND.seconds + 0.4, (states, heldSeconds) => void paintHourglass(states, clamp01((heldSeconds - SAND.startsAt) / SAND.seconds))), departure: HOURGLASS_RUN_OUT },			actionBeat(MOUSE_AT_REST, CAPTIONS.scroll, ROLL.startsAt + ROLL.seconds * ROLL.times + 0.2, (states, heldSeconds) => void paintMouse(states, rolledAt(heldSeconds))),			{ picture: PAGE_LOADING, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.3 },			{ ...actionBeat(PAGE_RENDERED, CAPTIONS.scrape, 3.4), arrival: { kind: 'builds', seconds: RENDER_SECONDS, order: renderingOrder } },		],	});}
components/visuals/stories/actions-story-scenes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, distanceToSegment, isFilledAmongSolids, paintShape, polygon, solidWithSpeckle, type DotShape } from '@/components/visuals/dot-engine/dot-story-cells';import { storyCaption } from '@/components/visuals/dot-engine/story-caption';/** * The "Read dynamic pages" card's pictures, in the brand dot grid: the browser actions a scrape can take before it * reads, a pointer that clicks, an hourglass that waits and a mouse that scrolls, and the page they leave rendered. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const ACTIONS_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = ACTIONS_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(ACTIONS_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;// Every picture stands in the same place, left of the words that name it.const CENTER = 16;const PICTURE_COLUMNS = 31;const POINTER = { tip: { column: 8, row: 6 }, click: { from: 2, to: 5.5, width: 0.6 } };// The arrow's outline, measured from its tip; its two long edges run at 45 degrees, so they step cleanly dot by dot.const POINTER_OUTLINE = [	[0, 0],	[0, 23],	[5.5, 17.5],	[9.5, 26],	[13, 24.5],	[9, 16.5],	[16.5, 16.5],];const arrow = polygon(	POINTER_OUTLINE.map(([across, down]) => ({ column: POINTER.tip.column + across, row: POINTER.tip.row + down })),	solidWithSpeckle);/** The pointer, with a click spreading `clicked` of the way out from its tip (none when 0). */export function paintPointer(states: Uint8Array, clicked: number) {	const reach = POINTER.click.from + clicked * (POINTER.click.to - POINTER.click.from);	for (let row = 0; row < ROWS; row++) {		for (let column = 0; column < PICTURE_COLUMNS; column++) {			const pointer = arrow(column, row);			const fromTip = Math.hypot(column - POINTER.tip.column, row - POINTER.tip.row);			// The click only spreads away from the arrow, up and to the left of its tip.			const isAhead = column - POINTER.tip.column + (row - POINTER.tip.row) < 1;			const isClick = clicked > 0 && clicked < 1 && isAhead && Math.abs(fromTip - reach) < POINTER.click.width;			states[row * COLUMNS + column] = pointer !== DOT_ABSENT ? pointer : isClick ? DOT_HOLLOW : DOT_ABSENT;		}	}	return states;}const HOURGLASS = { top: 4, bottom: 33, cap: 2, capHalfWidth: 10, neck: 18.5, neckHalfWidth: 1.2, bulbHalfWidth: 8.6, roundness: 0.75, sand: { from: 10, heap: 8, slope: 0.55 } };const GLASS = { top: HOURGLASS.top + HOURGLASS.cap, bottom: HOURGLASS.bottom - HOURGLASS.cap };const glassHalfWidth = (row: number) => HOURGLASS.neckHalfWidth + (HOURGLASS.bulbHalfWidth - HOURGLASS.neckHalfWidth) * (Math.abs(row - HOURGLASS.neck) / (HOURGLASS.neck - GLASS.top)) ** HOURGLASS.roundness;const isCap: DotShape = (column, row) => (between(row, HOURGLASS.top, GLASS.top - 1) || between(row, GLASS.bottom + 1, HOURGLASS.bottom)) && Math.abs(column - CENTER) <= HOURGLASS.capHalfWidth;const isGlass: DotShape = (column, row) => between(row, GLASS.top, GLASS.bottom) && Math.abs(column - CENTER) <= glassHalfWidth(row);/** The hourglass with `run` of its sand through: the top drains level, a stream falls, and a heap builds below. */export function paintHourglass(states: Uint8Array, run: number) {	const level = HOURGLASS.sand.from + run * (HOURGLASS.neck - HOURGLASS.sand.from);	const heapTop = GLASS.bottom - 1 - run * HOURGLASS.sand.heap;	const isSand = (column: number, row: number) => {		if (row < HOURGLASS.neck) return row >= level;		if (column === CENTER && run > 0 && run < 1) return true;		return row >= heapTop + Math.abs(column - CENTER) * HOURGLASS.sand.slope;	};	states.fill(DOT_ABSENT);	paintShape(ACTIONS_STORY_GRID, states, isGlass, (column, row) => (isSand(column, row) ? DOT_FILLED : DOT_HOLLOW));	return paintShape(ACTIONS_STORY_GRID, states, isCap, () => DOT_FILLED);}const MOUSE = { spine: { from: 13, to: 24 }, halfWidth: 8.5, wheel: { halfWidth: 1, rows: 4, from: 8, travel: 4 }, chevrons: { from: 18, every: 4, count: 3, halfWidth: 3 } };const MOUSE_SPINE = { from: { column: CENTER, row: MOUSE.spine.from }, to: { column: CENTER, row: MOUSE.spine.to } };const isMouse: DotShape = (column, row) => distanceToSegment(column, row, MOUSE_SPINE.from, MOUSE_SPINE.to) <= MOUSE.halfWidth;/** The mouse, its wheel `rolled` of the way down its slot, and the chevrons under it, cut out of its rings, lit one after another. */export function paintMouse(states: Uint8Array, rolled: number) {	const wheelTop = MOUSE.wheel.from + Math.round(rolled * MOUSE.wheel.travel);	const litChevron = Math.min(MOUSE.chevrons.count - 1, Math.floor(rolled * MOUSE.chevrons.count));	states.fill(DOT_ABSENT);	return paintShape(ACTIONS_STORY_GRID, states, isMouse, (column, row) => {		const across = Math.abs(column - CENTER);		if (across <= MOUSE.wheel.halfWidth && between(row, wheelTop, wheelTop + MOUSE.wheel.rows - 1)) return DOT_FILLED;		const chevron = Math.floor((row + across - MOUSE.chevrons.from) / MOUSE.chevrons.every);		const isChevron = across <= MOUSE.chevrons.halfWidth && (row + across - MOUSE.chevrons.from) % MOUSE.chevrons.every === 0 && between(chevron, 0, MOUSE.chevrons.count - 1);		if (!isChevron) return DOT_HOLLOW;		return chevron === litChevron ? DOT_FILLED : DOT_ABSENT;	});}const PAGE = { left: 5, right: 27, top: 3, bottom: 33, titleBarBottom: 6, buttons: { row: 5, columns: [7, 9, 11] } };const PAGE_BODY = { left: PAGE.left + 2, right: PAGE.right - 2, top: PAGE.titleBarBottom + 2, bottom: PAGE.bottom - 2 };const TICK = { center: { column: 25, row: 30 }, radius: 5.2, from: { column: 22.4, row: 30.2 }, corner: { column: 24.2, row: 32 }, to: { column: 27.8, row: 27.6 }, width: 0.75 };// A pricing page: a heading, a picture, two lines of copy, three plans and a button.const isContent = (column: number, row: number) => {	const across = column - PAGE_BODY.left;	const down = row - PAGE_BODY.top;	if (between(down, 0, 1)) return across <= 11;	if (between(down, 3, 8)) return true;	if (down === 10) return across <= 15;	if (down === 12) return across <= 11;	if (between(down, 14, 19)) return across % 7 <= 4;	if (between(down, 21, 22)) return across <= 7;	return false;};const isPage: DotShape = (column, row) => between(column, PAGE.left, PAGE.right) && between(row, PAGE.top, PAGE.bottom);const isTickBadge: DotShape = (column, row) => Math.hypot(column - TICK.center.column, row - TICK.center.row) <= TICK.radius;const isTick = (column: number, row: number) => Math.min(distanceToSegment(column, row, TICK.from, TICK.corner), distanceToSegment(column, row, TICK.corner, TICK.to)) < TICK.width;/** The page, its content either still loading (rings) or rendered (solid, with the tick that says so). */function paintPage(states: Uint8Array, isRendered: boolean) {	paintShape(ACTIONS_STORY_GRID, states, isPage, (column, row) => {		if (row <= PAGE.titleBarBottom) return row === PAGE.buttons.row && PAGE.buttons.columns.includes(column) ? DOT_HOLLOW : DOT_FILLED;		if (!between(column, PAGE_BODY.left, PAGE_BODY.right) || !isContent(column, row)) return DOT_ABSENT;		return isRendered && isFilledAmongSolids(column, row) ? DOT_FILLED : DOT_HOLLOW;	});	if (isRendered) paintShape(ACTIONS_STORY_GRID, states, isTickBadge, (column, row) => (isTick(column, row) ? DOT_HOLLOW : DOT_FILLED));	return states;}/** How far through rendering a dot is reached: the page fills in from its top down. */export const renderingOrder = (_column: number, row: number) => Math.min(1, Math.max(0, (row - PAGE_BODY.top) / (PAGE.bottom + 2 - PAGE_BODY.top)));export const POINTER_AT_REST = pictureOfStates(paintPointer(blank(), 0));export const HOURGLASS_AT_REST = pictureOfStates(paintHourglass(blank(), 0));export const HOURGLASS_RUN_OUT = pictureOfStates(paintHourglass(blank(), 1));export const MOUSE_AT_REST = pictureOfStates(paintMouse(blank(), 0));export const PAGE_LOADING = pictureOfStates(paintPage(blank(), false));export const PAGE_RENDERED = pictureOfStates(paintPage(blank(), true));export const CAPTIONS = {	click: storyCaption(ACTIONS_STORY_GRID, 33, 'action 1', 'click', '"Show all plans"'),	wait: storyCaption(ACTIONS_STORY_GRID, 33, 'action 2', 'wait', 'for the prices to load'),	scroll: storyCaption(ACTIONS_STORY_GRID, 33, 'action 3', 'scroll', 'to the end of the page'),	scrape: storyCaption(ACTIONS_STORY_GRID, 33, 'then', 'scrape', 'the page, fully rendered'),};/** The rendered page: what the picture shows before it plays, and instead of playing with reduced motion. */export const ACTIONS_RESTING = { ...PAGE_RENDERED, texts: CAPTIONS.scrape };

Reference: site src/components/sections/products/actions-story.tsx.