Discover storyDesign system
VisualSite

Discover story

Dot animation of domain discovery and URL results.

npx context-ds add story-discover
On the site

Usage

Use it for

Discovering or mapping URLs on a site card. Plays only on screen; shows the list with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

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

Source

components/visuals/stories/discover-story.tsx
'use client';import { DISCOVER_RESTING, DISCOVER_STORY_GRID } from '@/components/visuals/stories/discover-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadDiscoverStory: LoadDotStory = async (stage) => {	const { createDiscoverStoryPlayer } = await import('@/components/visuals/stories/discover-story-player');	return createDiscoverStoryPlayer(stage);};/** * The "Discover page URLs" card's picture: a radar sweeps a domain and finds its pages, then becomes the list of * their URLs. With reduced motion it shows the list. */export function DiscoverStory({ className }: { className?: string }) {	return <DotStory grid={DISCOVER_STORY_GRID} resting={DISCOVER_RESTING} load={loadDiscoverStory} className={className} />;}
components/visuals/stories/discover-story-player.ts
import { DISCOVER_STORY_GRID, LIST_FILLED, LIST_READ, PAGES_FOUND, RADAR_AT_REST, RADAR_CAPTION, RADAR_SWEPT, SWEEPS, URL_TEXTS, paintRadar, readingOrder } from '@/components/visuals/stories/discover-story-scenes';import { createBeatStoryPlayer } 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 discovery, played as a loop: a radar sweeps the domain twice, a blip lighting for each page it finds while * the count climbs; the radar melts into a list, and the list clears one row at a time as each URL is written in. */const MELT_SECONDS = 1.1;const SWEEP = { startsAt: 0.3, seconds: 2.8 };const READ = { seconds: 1.4, everySeconds: 0.25, textSeconds: 0.3 };const DETAIL_FADE_SECONDS = 0.3;const sweptAt = (heldSeconds: number) => clamp01((heldSeconds - SWEEP.startsAt) / SWEEP.seconds);export function createDiscoverStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const [domain, count, detail] = RADAR_CAPTION;	return createBeatStoryPlayer(stage, {		grid: DISCOVER_STORY_GRID,		resting: { beat: 2, heldSeconds: 2.4 },		beats: [			{				picture: RADAR_AT_REST,				departure: RADAR_SWEPT,				arrival: { kind: 'melts', seconds: MELT_SECONDS },				holdSeconds: SWEEP.startsAt + SWEEP.seconds + 0.9,				animate: (states, heldSeconds) => void paintRadar(states, sweptAt(heldSeconds) * SWEEPS),				write(pen, heldSeconds) {					pen.text(domain);					pen.text({ ...count, text: String(Math.round(sweptAt(heldSeconds) * PAGES_FOUND)) });					pen.text(detail, clamp01((heldSeconds - SWEEP.startsAt) / DETAIL_FADE_SECONDS));				},			},			{ picture: LIST_FILLED, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.2 },			{				picture: LIST_READ,				arrival: { kind: 'builds', seconds: READ.seconds, order: readingOrder },				holdSeconds: 3.4,				write: (pen, heldSeconds) => URL_TEXTS.forEach((url, index) => pen.text(url, clamp01((heldSeconds - index * READ.everySeconds) / READ.textSeconds))),			},		],	});}
components/visuals/stories/discover-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, paintShape, type DotPainter, type DotShape } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';import { storyCaption } from '@/components/visuals/dot-engine/story-caption';/** * The "Discover page URLs" card's pictures, in the brand dot grid: a radar that sweeps a domain and finds its pages * as blips, and the list of URLs the sweep brings back. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const DISCOVER_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS, pitch: PITCH } = DISCOVER_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 pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(DISCOVER_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const between = (value: number, from: number, to: number) => value >= from && value <= to;const TURN = Math.PI * 2;const RADAR = { center: { column: 16, row: 19 }, radius: 14.4, ranges: [4.8, 9.6], rangeWidth: 0.5, hub: 1.2, beam: 0.09, wake: 0.16 };// Each page the sweep finds, by where it is on the dial: how far round (in turns, from 12 o'clock) and how far out.const BLIPS = [	{ turns: 0.1, out: 7 },	{ turns: 0.27, out: 11.5 },	{ turns: 0.44, out: 5.5 },	{ turns: 0.6, out: 11 },	{ turns: 0.78, out: 7.5 },	{ turns: 0.93, out: 12 },].map(({ turns, out }) => ({ turns, column: Math.round(RADAR.center.column + out * Math.sin(turns * TURN)), row: Math.round(RADAR.center.row - out * Math.cos(turns * TURN)) }));const isRadar: DotShape = (column, row) => Math.hypot(column - RADAR.center.column, row - RADAR.center.row) <= RADAR.radius;/** The radar `swept` turns into its sweep: a solid beam with a wake behind it, and a blip for every page it has passed. */export function paintRadar(states: Uint8Array, swept: number) {	const beam = swept % 1;	states.fill(DOT_ABSENT);	return paintShape(DISCOVER_STORY_GRID, states, isRadar, (column, row) => {		const across = column - RADAR.center.column;		const up = RADAR.center.row - row;		const fromCenter = Math.hypot(across, up);		if (fromCenter <= RADAR.hub) return DOT_FILLED;		const blip = BLIPS.find((candidate) => Math.max(Math.abs(column - candidate.column), Math.abs(row - candidate.row)) <= 1);		if (blip && swept >= blip.turns) return column === blip.column && row === blip.row ? DOT_FILLED : DOT_ABSENT;		const behindBeam = (((beam - Math.atan2(across, up) / TURN) % 1) + 1) % 1;		if (swept > 0 && swept < SWEEPS && behindBeam < RADAR.wake) return behindBeam < RADAR.beam / Math.max(1, fromCenter / 4) ? DOT_FILLED : DOT_ABSENT;		if (RADAR.ranges.some((range) => Math.abs(fromCenter - range) < RADAR.rangeWidth) || across === 0 || up === 0) return DOT_ABSENT;		return DOT_HOLLOW;	});}/** How many times the beam goes round; it stops where it started. */export const SWEEPS = 2;export const RADAR_AT_REST = pictureOfStates(paintRadar(blank(), 0));export const RADAR_SWEPT = pictureOfStates(paintRadar(blank(), SWEEPS));export const PAGES_FOUND = 214;export const RADAR_CAPTION = storyCaption(DISCOVER_STORY_GRID, 34, 'stripe.com', String(PAGES_FOUND), 'page URLs discovered');const LIST = { left: 3, right: 54, top: 2, rowHeight: 6, every: 7, tab: 5 };const URLS = ['stripe.com/pricing', 'stripe.com/payments', 'stripe.com/docs/api', 'stripe.com/customers', 'stripe.com/blog/changelog'];const rowTop = (index: number) => LIST.top + index * LIST.every;export const listRowAt = (row: number) => Math.min(URLS.length - 1, Math.max(0, Math.floor((row - LIST.top) / LIST.every)));// Each URL is a bar with a solid tab, a link's ring cut out of it. Full of rings the list has enough in it to melt;// cleared, each bar has room for its URL.const urlList = (isCleared: boolean): DotPainter => (column, row) => {	const index = listRowAt(row);	const down = row - rowTop(index);	if (!between(column, LIST.left, LIST.right) || !between(down, 0, LIST.rowHeight - 1)) return DOT_ABSENT;	if (column <= LIST.left + LIST.tab) return between(down, 2, 3) && between(column, LIST.left + 2, LIST.left + 3) ? DOT_HOLLOW : DOT_FILLED;	if (column === LIST.right || down === 0 || down === LIST.rowHeight - 1) return DOT_FILLED;	return isCleared ? DOT_ABSENT : DOT_HOLLOW;};export const LIST_FILLED = pictureOf(DISCOVER_STORY_GRID, urlList(false));export const LIST_READ = pictureOf(DISCOVER_STORY_GRID, urlList(true));/** How far through the reading a dot is reached: the URLs in order, each from its tab to its end. */export const readingOrder = (column: number, row: number) => (listRowAt(row) + column / COLUMNS) / URLS.length;export const URL_TEXTS: DotStoryText[] = URLS.map((url, index) => ({ text: url, x: x(LIST.left + LIST.tab + 3), y: baselineOn(rowTop(index) + 2.5, 16), size: 16, weight: 700 }));/** The list of URLs: what the picture shows before it plays, and instead of playing with reduced motion. */export const DISCOVER_RESTING = { ...LIST_READ, texts: URL_TEXTS };

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