Site map storyDesign system
VisualSite

Site map story

Dot animation of a domain's page tree and page selection.

npx context-ds add story-site-map
On the site

Usage

Use it for

Site mapping on a site card or product hero. Plays only on screen; shows the tree with its pages picked with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

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

Source

components/visuals/stories/site-map-story.tsx
'use client';import { SITE_MAP_RESTING, SITE_MAP_STORY_GRID } from '@/components/visuals/stories/site-map-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadSiteMapStory: LoadDotStory = async (stage) => {	const { createSiteMapStoryPlayer } = await import('@/components/visuals/stories/site-map-story-player');	return createSiteMapStoryPlayer(stage);};/** * The Map page's picture: a domain typed under a paper map becomes the site as a tree of its pages, and the pages to * scrape are picked. With reduced motion it shows the tree with its pages picked. */export function SiteMapStory({ className }: { className?: string }) {	return <DotStory grid={SITE_MAP_STORY_GRID} resting={SITE_MAP_RESTING} load={loadSiteMapStory} className={className} />;}
components/visuals/stories/site-map-story-player.ts
import { CHOSEN_COUNT, DOMAIN, FOUND_COUNT, PAPER_MAP, SITE_CHOSEN, SITE_FOUND, SITE_MAP_STORY_GRID, TREE_LABELS, chosenCountText, paintChosen } from '@/components/visuals/stories/site-map-story-scenes';import { createBeatStoryPlayer, typingSeconds } from '@/components/visuals/dot-engine/dot-story-beats';import { clamp01 } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One mapping, played as a loop: a domain is typed under a folded paper map; the map melts into the site itself, a * tree of its sections and their pages; then the pages to scrape are picked, one after another. */const MELT_SECONDS = 1.1;const TYPING = { startsAt: 0.4, charactersPerSecond: 9 };const TYPED_AT = TYPING.startsAt + typingSeconds(DOMAIN, TYPING.charactersPerSecond);const CHOOSING = { startsAt: 1.1, everySeconds: 0.4 };const CHOSEN_AT = CHOOSING.startsAt + (CHOSEN_COUNT - 1) * CHOOSING.everySeconds;const LABEL_FADE_SECONDS = 0.3;const chosenAt = (heldSeconds: number) => (heldSeconds < CHOOSING.startsAt ? 0 : Math.min(CHOSEN_COUNT, Math.floor((heldSeconds - CHOOSING.startsAt) / CHOOSING.everySeconds) + 1));export function createSiteMapStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: SITE_MAP_STORY_GRID,		resting: { beat: 1, heldSeconds: CHOSEN_AT + 1 },		beats: [			{ picture: PAPER_MAP, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: TYPED_AT + 1, write: (pen, heldSeconds) => pen.type(DOMAIN, heldSeconds, { ...TYPING, cursorLingers: Infinity }) },			{				picture: SITE_FOUND,				departure: SITE_CHOSEN,				arrival: { kind: 'melts', seconds: MELT_SECONDS },				holdSeconds: CHOSEN_AT + 3.2,				animate: (states, heldSeconds) => void paintChosen(states, chosenAt(heldSeconds)),				write(pen, heldSeconds) {					TREE_LABELS.forEach((label) => pen.text(label));					pen.text(FOUND_COUNT);					pen.text(chosenCountText(Math.max(1, chosenAt(heldSeconds))), clamp01((heldSeconds - CHOOSING.startsAt) / LABEL_FADE_SECONDS));				},			},		],	});}
components/visuals/stories/site-map-story-scenes.ts
import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { pictureOf, type StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, distanceToSegment, paintedStates, polygon, ringsWithSpeckle, type DotPainter, type GridPoint } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The Map page's pictures, in the brand dot grid: a folded paper map with a pin in it, and what mapping a domain * returns, the site as a tree of its sections and their pages, with the pages chosen for scraping picked out. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const SITE_MAP_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, pitch: PITCH } = SITE_MAP_STORY_GRID;const x = (column: number) => column * PITCH;// Doto sits a third of its size below the middle of the row it is set on.const baselineOn = (row: number, size: number) => (row + 0.5) * PITCH + size / 3;const between = (value: number, from: number, to: number) => value >= from && value <= to;const SHEET = { left: 8, right: 50, folds: [22, 36], top: 2, bottom: 26, rise: 4 };const PIN = { center: { column: 29, row: 13 }, radius: 4.3, hole: 1.5, tip: { column: 29, row: 22 }, moat: 1.6 };const ROUTE: GridPoint[] = [{ column: 11, row: 26 }, { column: 17, row: 20 }, { column: 23, row: 26 }, { column: 29, row: 25 }];const ROUTE_WIDTH = { line: 0.6, clearing: 1.5 };const BLOCKS = [	{ left: 40, right: 46, top: 8, bottom: 12 },	{ left: 11, right: 16, top: 9, bottom: 13 },	{ left: 39, right: 44, top: 19, bottom: 23 },];// How far a dot is inside the pin: its round head, or the taper from the head down to the tip. Negative is outside.const insidePin = (column: number, row: number) => {	const fromHead = Math.hypot(column - PIN.center.column, row - PIN.center.row);	const down = (row - PIN.center.row) / (PIN.tip.row - PIN.center.row);	const taper = between(down, 0, 1) ? PIN.radius * (1 - down) - Math.abs(column - PIN.center.column) : -Infinity;	return Math.max(PIN.radius - fromHead, taper);};// A map folded in three, its panels tilting alternately; on it a few city blocks, a route, and the pin it leads to.const foldedMap = polygon(	[		{ column: SHEET.left, row: SHEET.top + SHEET.rise },		{ column: SHEET.folds[0], row: SHEET.top },		{ column: SHEET.folds[1], row: SHEET.top + SHEET.rise },		{ column: SHEET.right, row: SHEET.top },		{ column: SHEET.right, row: SHEET.bottom },		{ column: SHEET.folds[1], row: SHEET.bottom + SHEET.rise },		{ column: SHEET.folds[0], row: SHEET.bottom },		{ column: SHEET.left, row: SHEET.bottom + SHEET.rise },	],	(column, row) => {		const pin = insidePin(column, row);		if (pin >= 0) return Math.hypot(column - PIN.center.column, row - PIN.center.row) <= PIN.hole ? DOT_HOLLOW : DOT_FILLED;		if (pin > -PIN.moat) return DOT_ABSENT;		if (SHEET.folds.includes(column)) return DOT_FILLED;		const fromRoute = Math.min(...ROUTE.slice(1).map((point, index) => distanceToSegment(column, row, ROUTE[index], point)));		if (fromRoute < ROUTE_WIDTH.line) return DOT_FILLED;		if (fromRoute < ROUTE_WIDTH.clearing) return DOT_ABSENT;		if (BLOCKS.some((block) => between(column, block.left, block.right) && between(row, block.top, block.bottom))) return ringsWithSpeckle(column, row) === DOT_FILLED ? DOT_HOLLOW : DOT_FILLED;		return DOT_HOLLOW;	});export const PAPER_MAP = pictureOf(SITE_MAP_STORY_GRID, foldedMap);const DOMAIN_SIZE = 20;const DOMAIN_NAME = 'stripe.com';// Doto is monospaced, each letter three fifths of its size wide; typed text is set from its left so the cursor can follow it.export const DOMAIN: DotStoryText = { text: DOMAIN_NAME, x: x(COLUMNS / 2) - (DOMAIN_NAME.length * 0.6 * DOMAIN_SIZE) / 2, y: baselineOn(34, DOMAIN_SIZE), size: DOMAIN_SIZE, weight: 800 };const ROOT = { left: 18, right: 38, top: 1, bottom: 6, center: 28 };const BRANCH_ROW = 10;const SECTION = { columns: 17, top: 13, bottom: 18 };const SECTIONS = [	{ left: 0, path: '/pricing' },	{ left: 20, path: '/docs' },	{ left: 40, path: '/blog' },];const TILE = { columns: 5, rows: 5, every: 6, tops: [22, 29], perRow: 3 };const centerOf = (left: number) => left + (SECTION.columns - 1) / 2;interface Tile {	left: number;	top: number;}const TILES: Tile[] = SECTIONS.flatMap(({ left }) => TILE.tops.flatMap((top) => Array.from({ length: TILE.perRow }, (_, index) => ({ left: left + index * TILE.every, top }))));/** The pages picked for scraping, in the order they are picked. */const CHOSEN_TILES = [1, 3, 8, 10, 12, 16];const tileAt = (column: number, row: number) => TILES.findIndex(({ left, top }) => between(column, left, left + TILE.columns - 1) && between(row, top, top + TILE.rows - 1));const isBoxEdge = (column: number, row: number, box: { left: number; right: number; top: number; bottom: number }) =>	between(column, box.left, box.right) && between(row, box.top, box.bottom) && (column === box.left || column === box.right || row === box.top || row === box.bottom);// The site as a tree: the domain at the root, a branch to each section, and under each section its pages. A page// that has been found is an outline full of rings; one chosen for scraping is solid.const tree = (isChosen: (tile: number) => boolean): DotPainter => (column, row) => {	if (isBoxEdge(column, row, ROOT)) return DOT_FILLED;	if (column === ROOT.center && between(row, ROOT.bottom, BRANCH_ROW)) return DOT_FILLED;	if (row === BRANCH_ROW && between(column, centerOf(SECTIONS[0].left), centerOf(SECTIONS[SECTIONS.length - 1].left))) return DOT_FILLED;	const section = SECTIONS.find(({ left }) => between(column, left, left + SECTION.columns - 1));	if (!section) return DOT_ABSENT;	if (column === centerOf(section.left) && (between(row, BRANCH_ROW, SECTION.top) || between(row, SECTION.bottom, TILE.tops[0] - 1))) return DOT_FILLED;	if (isBoxEdge(column, row, { left: section.left, right: section.left + SECTION.columns - 1, top: SECTION.top, bottom: SECTION.bottom })) return DOT_FILLED;	const tile = tileAt(column, row);	if (tile < 0) return DOT_ABSENT;	const isEdge = column === TILES[tile].left || column === TILES[tile].left + TILE.columns - 1 || row === TILES[tile].top || row === TILES[tile].top + TILE.rows - 1;	return isEdge || isChosen(tile) ? DOT_FILLED : DOT_HOLLOW;};const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(SITE_MAP_STORY_GRID, states) });export const SITE_FOUND = pictureOfStates(paintedStates(SITE_MAP_STORY_GRID, tree(() => false)));export const SITE_CHOSEN = pictureOfStates(paintedStates(SITE_MAP_STORY_GRID, tree((tile) => CHOSEN_TILES.includes(tile))));export const CHOSEN_COUNT = CHOSEN_TILES.length;/** Picks the first `count` of the chosen pages. */export function paintChosen(states: Uint8Array, count: number) {	for (let cell = 0; cell < states.length; cell++) {		const tile = tileAt(cell % COLUMNS, Math.floor(cell / COLUMNS));		if (tile >= 0 && CHOSEN_TILES.indexOf(tile) < count) states[cell] = SITE_CHOSEN.states[cell];	}	return states;}export const TREE_LABELS: DotStoryText[] = [	{ text: DOMAIN_NAME, x: x(ROOT.center + 0.5), y: baselineOn((ROOT.top + ROOT.bottom) / 2, 16), size: 16, weight: 800, anchor: 'middle' },	...SECTIONS.map(({ left, path }): DotStoryText => ({ text: path, x: x(centerOf(left) + 0.5), y: baselineOn((SECTION.top + SECTION.bottom) / 2, 15), size: 15, weight: 700, anchor: 'middle' })),];const COUNT_LEFT = x(ROOT.right + 3);export const FOUND_COUNT: DotStoryText = { text: `${TILES.length} pages found`, x: COUNT_LEFT, y: baselineOn(2, 13), size: 13, weight: 700, muted: true };export const chosenCountText = (count: number): DotStoryText => ({ text: `${count} to scrape`, x: COUNT_LEFT, y: baselineOn(4.6, 13), size: 13, weight: 800 });/** The mapped site with its pages chosen: what the picture shows before it plays, and instead of playing with reduced motion. */export const SITE_MAP_RESTING = { ...SITE_CHOSEN, texts: [...TREE_LABELS, FOUND_COUNT, chosenCountText(CHOSEN_COUNT)] };

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