Question storyDesign system
VisualSite

Question story

Dot animation of pages being searched to answer a question.

npx context-ds add story-question
On the site

Usage

Use it for

Starting research from a question on a site card. Plays only on screen; shows the question with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

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

Source

components/visuals/stories/question-story.tsx
'use client';import { QUESTION_RESTING, QUESTION_STORY_GRID } from '@/components/visuals/stories/question-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadQuestionStory: LoadDotStory = async (stage) => {	const { createQuestionStoryPlayer } = await import('@/components/visuals/stories/question-story-player');	return createQuestionStoryPlayer(stage);};/** * The "Start with a question" card's picture: a pile of pages becomes the question asked instead, then the * magnifier that reads for you. With reduced motion it shows the question. */export function QuestionStory({ className }: { className?: string }) {	return <DotStory grid={QUESTION_STORY_GRID} resting={QUESTION_RESTING} load={loadQuestionStory} className={className} />;}
components/visuals/stories/question-story-player.ts
import { CAPTIONS, MAGNIFIER_AT_REST, PAGES, QUESTION_MARK, QUESTION_STORY_GRID, READING_REPEATS_EVERY, paintMagnifier } from '@/components/visuals/stories/question-story-scenes';import { createBeatStoryPlayer, typingSeconds, type StoryBeat } 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 research task, played as a loop: the pile of pages it would take by hand is counted up, melts into the * question that is asked instead, and the question melts into a magnifier with the pages going by under its glass. */const MELT_SECONDS = 1.1;const COUNTING = { startsAt: 0.2, seconds: 1.2, pages: 9 };const TYPING = { startsAt: 0.15, charactersPerSecond: 12 };const DETAIL_FADE_SECONDS = 0.3;// The page under the glass is back where it started as the magnifier melts away.const READING_SECONDS = 3;const melts: StoryBeat['arrival'] = { kind: 'melts', seconds: MELT_SECONDS };export function createQuestionStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const [byHandLabel, byHandCount, byHandDetail] = CAPTIONS.byHand;	const [askLabel, ask, question] = CAPTIONS.ask;	const [readLabel, reads, readDetail] = CAPTIONS.read;	const askedAt = TYPING.startsAt + typingSeconds(ask, TYPING.charactersPerSecond);	return createBeatStoryPlayer(stage, {		grid: QUESTION_STORY_GRID,		resting: { beat: 1, heldSeconds: 2 },		beats: [			{				picture: PAGES,				arrival: melts,				holdSeconds: 2.4,				write(pen, heldSeconds) {					const counted = Math.max(1, Math.ceil(clamp01((heldSeconds - COUNTING.startsAt) / COUNTING.seconds) * COUNTING.pages));					pen.text(byHandLabel);					pen.text({ ...byHandCount, text: `${counted} ${counted === 1 ? 'page' : 'pages'}` });					pen.text(byHandDetail, clamp01((heldSeconds - COUNTING.startsAt - COUNTING.seconds) / DETAIL_FADE_SECONDS));				},			},			{				picture: QUESTION_MARK,				arrival: melts,				holdSeconds: 2.8,				write(pen, heldSeconds) {					pen.text(askLabel);					pen.type(ask, heldSeconds, TYPING);					pen.text(question, clamp01((heldSeconds - askedAt) / DETAIL_FADE_SECONDS));				},			},			{				picture: MAGNIFIER_AT_REST,				arrival: melts,				holdSeconds: READING_SECONDS,				animate: (states, heldSeconds) => void paintMagnifier(states, (heldSeconds / READING_SECONDS) * READING_REPEATS_EVERY),				write(pen, heldSeconds) {					pen.text(readLabel);					pen.text(reads);					pen.text(readDetail, clamp01((heldSeconds - 0.4) / DETAIL_FADE_SECONDS));				},			},		],	});}
components/visuals/stories/question-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, paintShape, ringsWithSpeckle, solidWithSpeckle, type DotPainter, type DotShape, type GridPoint } from '@/components/visuals/dot-engine/dot-story-cells';import { storyCaption } from '@/components/visuals/dot-engine/story-caption';/** * The "Start with a question" card's pictures, in the brand dot grid: the pile of pages a research task used to * mean gathering by hand, the question that replaces it, and the magnifier that reads the pages for you. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const QUESTION_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = QUESTION_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(QUESTION_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 say what it means.const CENTER = 16;const SHEET = { columns: 18, rows: 22 };// Back to front, each sheet lower and further right than the one behind it.const PILE = [	{ left: 2, top: 3, paper: solidWithSpeckle },	{ left: 7, top: 8, paper: ringsWithSpeckle },	{ left: 12, top: 13, paper: null },];// The top sheet is the only one that can be read: a heading and lines of copy.const writing: DotPainter = (across, down) => {	if (between(down, 3, 4)) return between(across, 3, 11) ? DOT_FILLED : DOT_ABSENT;	if (down >= 7 && down <= 17 && down % 2 === 1) return between(across, 3, down % 3 === 0 ? 10 : 14) ? DOT_HOLLOW : DOT_ABSENT;	return DOT_ABSENT;};// Each sheet covers the ones behind it, so its empty paper hides them rather than letting them show through.const pile: DotPainter = (column, row) => {	const sheet = PILE.findLast(({ left, top }) => between(column, left, left + SHEET.columns - 1) && between(row, top, top + SHEET.rows - 1));	if (!sheet) return DOT_ABSENT;	const across = column - sheet.left;	const down = row - sheet.top;	if (across === 0 || down === 0 || across === SHEET.columns - 1 || down === SHEET.rows - 1) return DOT_FILLED;	return sheet.paper ? sheet.paper(column, row) : writing(across, down);};export const PAGES = pictureOf(QUESTION_STORY_GRID, pile);const MARK = { hook: { center: { column: CENTER, row: 11 }, radius: 6.2, from: -1.05, to: 0.42, steps: 24 }, stem: { column: CENTER, from: 19.5, to: 23.5 }, stroke: 2.3, dot: { center: { column: CENTER, row: 30.5 }, radius: 2.3 } };const hookPoint = (step: number): GridPoint => {	const angle = Math.PI * (MARK.hook.from + ((MARK.hook.to - MARK.hook.from) * step) / MARK.hook.steps);	return { column: MARK.hook.center.column + MARK.hook.radius * Math.cos(angle), row: MARK.hook.center.row + MARK.hook.radius * Math.sin(angle) };};// The stroke of the mark: round the hook, back in under it, and straight down the stem.const MARK_STROKE: GridPoint[] = [...Array.from({ length: MARK.hook.steps + 1 }, (_, step) => hookPoint(step)), { column: MARK.stem.column, row: MARK.stem.from }, { column: MARK.stem.column, row: MARK.stem.to }];const isQuestionMark: DotShape = (column, row) =>	Math.hypot(column - MARK.dot.center.column, row - MARK.dot.center.row) <= MARK.dot.radius || MARK_STROKE.some((point, index) => index > 0 && distanceToSegment(column, row, MARK_STROKE[index - 1], point) <= MARK.stroke);export const QUESTION_MARK = pictureOfStates(paintShape(QUESTION_STORY_GRID, blank(), isQuestionMark, solidWithSpeckle));const LENS = { center: { column: 13, row: 15 }, radius: 11.4, rim: 2 };const HANDLE = { from: { column: 21.5, row: 23.5 }, to: { column: 28, row: 30 }, width: 2.2 };const READING = { every: 3, left: -7, right: 7, lines: 3, word: { columns: 4, step: 4, from: -6 } };const isMagnifier: DotShape = (column, row) => Math.hypot(column - LENS.center.column, row - LENS.center.row) <= LENS.radius || distanceToSegment(column, row, HANDLE.from, HANDLE.to) <= HANDLE.width;/** The magnifier, with the lines of the page under its glass moved `read` rows up it. */export function paintMagnifier(states: Uint8Array, read: number) {	states.fill(DOT_ABSENT);	return paintShape(QUESTION_STORY_GRID, states, isMagnifier, (column, row) => {		const fromCenter = Math.hypot(column - LENS.center.column, row - LENS.center.row);		if (fromCenter > LENS.radius) return DOT_FILLED;		if (fromCenter > LENS.radius - 1 - LENS.rim) return DOT_HOLLOW;		const line = row + Math.floor(read);		const across = column - LENS.center.column;		if (line % READING.every !== 0 || !between(across, READING.left, READING.right)) return DOT_ABSENT;		// Every third line is short, and one word in each is solid: it reads as copy going by, not as stripes.		const lineNumber = line / READING.every;		const lineOfThree = ((lineNumber % READING.lines) + READING.lines) % READING.lines;		if (lineOfThree === 0 && across > 2) return DOT_ABSENT;		const wordFrom = READING.word.from + lineOfThree * READING.word.step;		return between(across, wordFrom, wordFrom + READING.word.columns - 1) ? DOT_FILLED : DOT_HOLLOW;	});}export const MAGNIFIER_AT_REST = pictureOfStates(paintMagnifier(blank(), 0));/** How far the page moves under the glass before it looks the same again. */export const READING_REPEATS_EVERY = READING.every * READING.lines;export const CAPTIONS = {	byHand: storyCaption(QUESTION_STORY_GRID, 34, 'by hand', '9 pages', 'to find, open and read'),	ask: storyCaption(QUESTION_STORY_GRID, 34, 'instead', 'ask', '"What does Linear cost?"'),	read: storyCaption(QUESTION_STORY_GRID, 34, 'then it', 'reads', 'the pages that answer it'),};/** The question: what the picture shows before it plays, and instead of playing with reduced motion. */export const QUESTION_RESTING = { ...QUESTION_MARK, texts: CAPTIONS.ask };

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