Crawl storyDesign system
VisualSite

Crawl story

Dot animation of a sitemap expanding into a crawl graph.

npx context-ds add story-crawl
On the site

Usage

Use it for

Crawling a whole site on a site card. Plays only on screen; shows the whole web with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

examples/story-crawl.tsx
import { Card } from "@/components/ui/card";import { CrawlStory } from "@/components/visuals/stories/crawl-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]">        <CrawlStory />      </div>    </Card>  );}

Source

components/visuals/stories/crawl-story.tsx
'use client';import { CRAWL_RESTING, CRAWL_STORY_GRID } from '@/components/visuals/stories/crawl-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadCrawlStory: LoadDotStory = async (stage) => {	const { createCrawlStoryPlayer } = await import('@/components/visuals/stories/crawl-story-player');	return createCrawlStoryPlayer(stage);};/** * The "Full-site collection" card's picture: a sitemap becomes a web, spun outwards a ring of links at a time to * the scope that was set. With reduced motion it shows the whole web. */export function CrawlStory({ className }: { className?: string }) {	return <DotStory grid={CRAWL_STORY_GRID} resting={CRAWL_RESTING} load={loadCrawlStory} className={className} />;}
components/visuals/stories/crawl-story-player.ts
import { CRAWL_STORY_GRID, DEPTH_CAPTIONS, MAX_DEPTH, SITEMAP, SITEMAP_CAPTION, WEB_SPUN, WEB_UNSPUN, paintWeb } from '@/components/visuals/stories/crawl-story-scenes';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One crawl, played as a loop: the sitemap it starts from melts into a web, and the web is spun outwards a ring of * links at a time, the depth counting up until it reaches the scope that was set. */const SPINNING = { startsAt: 0.7, everySeconds: 0.8 };const SPUN_AT = SPINNING.startsAt + (MAX_DEPTH - 1) * SPINNING.everySeconds;const depthAt = (heldSeconds: number) => (heldSeconds < SPINNING.startsAt ? 0 : Math.min(MAX_DEPTH, Math.floor((heldSeconds - SPINNING.startsAt) / SPINNING.everySeconds) + 1));export function createCrawlStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: CRAWL_STORY_GRID,		resting: { beat: 1, heldSeconds: SPUN_AT + 1 },		beats: [			captionedBeat({ picture: SITEMAP, caption: SITEMAP_CAPTION, holdSeconds: 2.4, isTyped: true }),			{				...captionedBeat({ picture: WEB_UNSPUN, departure: WEB_SPUN, caption: DEPTH_CAPTIONS[0], holdSeconds: SPUN_AT + 2.8, animate: (states, heldSeconds) => void paintWeb(states, depthAt(heldSeconds)) }),				write: (pen, heldSeconds) => DEPTH_CAPTIONS[depthAt(heldSeconds)].forEach((text) => pen.text(text)),			},		],	});}
components/visuals/stories/crawl-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';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, paintShape, roundedBox, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** * The "Full-site collection" card's pictures, in the brand dot grid: the sitemap a crawl can start from, and the * crawl itself, a web spun outwards from the start one ring of links at a time, as far as its scope allows. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const CRAWL_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = CRAWL_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(CRAWL_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const SHEET: DotBox = { left: 6, right: 26, top: 3, bottom: 34 };// The sitemap's entries: a section is a solid line, the pages under it indented lines of rings.const ENTRIES = [	{ row: 7, indent: 0, to: 16 },	{ row: 10, indent: 3, to: 22 },	{ row: 13, indent: 3, to: 19 },	{ row: 17, indent: 0, to: 14 },	{ row: 20, indent: 3, to: 23 },	{ row: 23, indent: 3, to: 20 },	{ row: 26, indent: 3, to: 22 },	{ row: 30, indent: 0, to: 17 },];const ENTRY_LEFT = 9;export const SITEMAP = pictureOfStates(	paintShape(CRAWL_STORY_GRID, blank(), roundedBox(SHEET, 2), (column, row) => {		const entry = ENTRIES.find((candidate) => candidate.row === row);		if (!entry || !between(column, ENTRY_LEFT + entry.indent, entry.to)) return DOT_ABSENT;		return entry.indent === 0 ? DOT_FILLED : DOT_HOLLOW;	}));const WEB = { center: { column: 16, row: 19 }, hub: 1.7, rings: [4.6, 9.2, 13.8], ringWidth: 0.55, spokes: 8, spokeWidth: 0.06 };/** How many links deep the crawl can go: one ring of the web for each. */export const MAX_DEPTH = WEB.rings.length;/** The web spun `depth` rings out from the start: what the crawl has reached is solid, what is still out of reach rings. */export function paintWeb(states: Uint8Array, depth: number) {	const reached = depth > 0 ? WEB.rings[depth - 1] + WEB.ringWidth : WEB.hub;	for (let row = 0; row < ROWS; row++) {		for (let column = 0; column < COLUMNS / 2 + 3; column++) {			const across = column - WEB.center.column;			const down = row - WEB.center.row;			const fromCenter = Math.hypot(across, down);			const spoke = (Math.atan2(down, across) / (Math.PI * 2)) * WEB.spokes;			const isOnSpoke = fromCenter <= WEB.rings[WEB.rings.length - 1] + WEB.ringWidth && Math.abs(spoke - Math.round(spoke)) * fromCenter < WEB.spokeWidth * WEB.spokes * 1.4;			const isOnRing = WEB.rings.some((ring) => Math.abs(fromCenter - ring) < WEB.ringWidth);			const cell = row * COLUMNS + column;			if (fromCenter <= WEB.hub) states[cell] = DOT_FILLED;			else if (isOnSpoke || isOnRing) states[cell] = fromCenter <= reached ? DOT_FILLED : DOT_HOLLOW;			else states[cell] = DOT_ABSENT;		}	}	return states;}export const WEB_UNSPUN = pictureOfStates(paintWeb(blank(), 0));export const WEB_SPUN = pictureOfStates(paintWeb(blank(), MAX_DEPTH));const caption = (label: string, value: string, detail: string) => storyCaption(CRAWL_STORY_GRID, 34, label, value, detail);export const SITEMAP_CAPTION = caption('start from', 'sitemap', 'or any URL on the site');export const DEPTH_CAPTIONS = Array.from({ length: MAX_DEPTH + 1 }, (_, depth) => caption('crawl depth', String(depth), depth === MAX_DEPTH ? 'the scope you set' : 'following links'));/** The whole web: what the picture shows before it plays, and instead of playing with reduced motion. */export const CRAWL_RESTING = { ...WEB_SPUN, texts: DEPTH_CAPTIONS[MAX_DEPTH] };

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