Answer storyDesign system
VisualSite

Answer story

Dot animation of a question, sources, and a structured answer.

npx context-ds add story-answer
On the site

Usage

Use it for

The research or answers product on a site card. Plays only on screen; shows its resting picture with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine

Example

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

Source

components/visuals/stories/answer-story.tsx
'use client';import { ANSWER_RESTING, ANSWER_STORY_GRID } from '@/components/visuals/stories/answer-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadAnswerStory: LoadDotStory = async (stage) => {	const { createAnswerStoryPlayer } = await import('@/components/visuals/stories/answer-story-player');	return createAnswerStoryPlayer(stage);};/** * The Research card's picture: a question is asked, its sources are read, and the answer is written between a pair * of braces in the shape that was asked for. With reduced motion it shows the answer. */export function AnswerStory({ className }: { className?: string }) {	return <DotStory grid={ANSWER_STORY_GRID} resting={ANSWER_RESTING} load={loadAnswerStory} className={className} />;}
components/visuals/stories/answer-story-player.ts
import { ANSWER, ANSWER_STORY_GRID, BOOKS, BOOK_SIZE, BRACES, CITED, FIELD_COUNT, FIELD_TEXTS, QUESTION, QUESTION_LINES, SOURCES, SOURCES_LABEL, THINKING, fieldAt } from '@/components/visuals/stories/answer-story-scenes';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';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 piece of research, played as a loop: the question is typed into its bubble and the bubble thinks; it melts * into the sources, which are read one after another; they melt into a pair of braces, and the answer is written * between them field by field. */const TYPING = { startsAt: 0.4, charactersPerSecond: 12 };const SECOND_LINE_AT = TYPING.startsAt + typingSeconds(QUESTION_LINES[0], TYPING.charactersPerSecond);const ASKED_AT = SECOND_LINE_AT + typingSeconds(QUESTION_LINES[1], TYPING.charactersPerSecond);const THINK = { everySeconds: 0.25 };const READ = { startsAt: 0.2, secondsEach: 0.7, width: 3 };const MELT_SECONDS = 1.1;const FIELD = { seconds: 1.4, everySeconds: 0.45, textSeconds: 0.3 };const { columns } = ANSWER_STORY_GRID;// The dots of an ellipsis, lit one after another once the question is asked.function think(states: Uint8Array, heldSeconds: number) {	for (const column of THINKING.columns) states[THINKING.row * columns + column] = DOT_ABSENT;	if (heldSeconds < ASKED_AT) return;	const lit = Math.floor((heldSeconds - ASKED_AT) / THINK.everySeconds) % (THINKING.columns.length + 1);	THINKING.columns.forEach((column, index) => {		if (index < lit) states[THINKING.row * columns + column] = DOT_FILLED;	});}// A band crosses each book in turn, turning its dots over as it goes: the page being read.function readSources(states: Uint8Array, heldSeconds: number) {	const reading = (heldSeconds - READ.startsAt) / READ.secondsEach;	const source = BOOKS[Math.floor(reading)];	if (reading < 0 || !source) return;	const band = source.left + BOOK_SIZE.spine + (reading % 1) * (BOOK_SIZE.columns - BOOK_SIZE.spine - BOOK_SIZE.pages);	for (let row = source.top + 1; row < source.top + BOOK_SIZE.rows - 1; row++) {		for (let column = Math.ceil(band - READ.width); column <= band; column++) {			const cell = row * columns + column;			if (column >= source.left + BOOK_SIZE.spine && states[cell] !== DOT_ABSENT) states[cell] = states[cell] === DOT_FILLED ? DOT_HOLLOW : DOT_FILLED;		}	}}export function createAnswerStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: ANSWER_STORY_GRID,		resting: { beat: 3, heldSeconds: 2.6 },		beats: [			{				picture: QUESTION,				arrival: { kind: 'melts', seconds: MELT_SECONDS },				holdSeconds: ASKED_AT + 1.2,				animate: think,				write(pen, heldSeconds) {					pen.type(QUESTION_LINES[0], heldSeconds, { ...TYPING, cursorLingers: 0 });					pen.type(QUESTION_LINES[1], heldSeconds, { ...TYPING, startsAt: SECOND_LINE_AT });				},			},			{ picture: SOURCES, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: READ.startsAt + BOOKS.length * READ.secondsEach + 0.3, animate: readSources, write: (pen) => pen.text(SOURCES_LABEL) },			{ picture: BRACES, arrival: { kind: 'melts', seconds: MELT_SECONDS }, holdSeconds: 0.1 },			{				picture: ANSWER,				arrival: { kind: 'builds', seconds: FIELD.seconds, order: (_column, row) => fieldAt(row) / FIELD_COUNT },				holdSeconds: 3.6,				write(pen, heldSeconds) {					FIELD_TEXTS.forEach((text, index) => pen.text(text, clamp01((heldSeconds - index * FIELD.everySeconds) / FIELD.textSeconds)));					pen.text(CITED, clamp01((heldSeconds - FIELD_COUNT * FIELD.everySeconds) / FIELD.textSeconds));				},			},		],	});}
components/visuals/stories/answer-story-scenes.ts
import { glyphCellsFromRows } from '@/components/visuals/dot-engine/dot-glyph-cells';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 { cellStates, cellsOfStates, placeCells, polygon, rectangle, ringsWithSpeckle, solidWithSpeckle, stack, type DotPainter } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The Research card's pictures, in the brand dot grid: the question as it is asked, the sources that are read to * answer it, and the answer in the shape that was asked for, a JSON object between its braces. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const ANSWER_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, pitch: PITCH } = ANSWER_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 BUBBLE = { left: 5, right: 52, top: 3, bottom: 25, corner: 2, band: 1 };const TAIL = { from: 12, to: 21, tip: { column: 10, row: 32 } };export const THINKING = { row: BUBBLE.bottom - 4, columns: [40, 43, 46] };// A heavy outline with a band of rings inside it, so the bubble has something in it to melt.const bubble = polygon(	[		{ column: BUBBLE.left + BUBBLE.corner, row: BUBBLE.top },		{ column: BUBBLE.right - BUBBLE.corner, row: BUBBLE.top },		{ column: BUBBLE.right, row: BUBBLE.top + BUBBLE.corner },		{ column: BUBBLE.right, row: BUBBLE.bottom - BUBBLE.corner },		{ column: BUBBLE.right - BUBBLE.corner, row: BUBBLE.bottom },		{ column: TAIL.to, row: BUBBLE.bottom },		TAIL.tip,		{ column: TAIL.from, row: BUBBLE.bottom },		{ column: BUBBLE.left + BUBBLE.corner, row: BUBBLE.bottom },		{ column: BUBBLE.left, row: BUBBLE.bottom - BUBBLE.corner },		{ column: BUBBLE.left, row: BUBBLE.top + BUBBLE.corner },	],	(column, row) => {		if (row > BUBBLE.bottom) return DOT_HOLLOW;		const depth = Math.min(column - BUBBLE.left, BUBBLE.right - column, row - BUBBLE.top, BUBBLE.bottom - row);		if (depth <= BUBBLE.band + 1) return DOT_HOLLOW;		return row === THINKING.row && THINKING.columns.includes(column) ? DOT_HOLLOW : DOT_ABSENT;	});export const QUESTION = pictureOf(ANSWER_STORY_GRID, bubble);export const QUESTION_LINES: DotStoryText[] = [	{ text: 'How does Mintlify use', x: x(BUBBLE.left + 5), y: baselineOn(BUBBLE.top + 6, 22), size: 22, weight: 700 },	{ text: 'Context.dev?', x: x(BUBBLE.left + 5), y: baselineOn(BUBBLE.top + 11, 22), size: 22, weight: 700 },];const BOOK = { columns: 40, rows: 8, spine: 4, pages: 3 };export const BOOKS = [	{ left: 10, top: 5, isDark: true },	{ left: 7, top: 15, isDark: false },	{ left: 11, top: 25, isDark: true },];// A book lying flat: a solid spine, the page edges as rings at its other end, and a title band across its cover.const book = ({ left, top, isDark }: (typeof BOOKS)[number]): DotPainter =>	rectangle({ left, right: left + BOOK.columns - 1, top, bottom: top + BOOK.rows - 1 }, (column, row) => {		if (column < left + BOOK.spine) return DOT_FILLED;		if (column >= left + BOOK.columns - BOOK.pages) return row % 2 === 0 ? DOT_HOLLOW : DOT_ABSENT;		const isTitle = (row === top + 3 || row === top + 4) && column >= left + BOOK.spine + 4 && column < left + BOOK.spine + 20;		if (isTitle) return isDark ? DOT_HOLLOW : DOT_FILLED;		return isDark ? solidWithSpeckle(column, row) : ringsWithSpeckle(column, row);	});export const SOURCES = pictureOf(ANSWER_STORY_GRID, stack(...BOOKS.map(book)));export const BOOK_SIZE = BOOK;export const SOURCES_LABEL: DotStoryText = { text: 'Reading 3 sources', x: x(COLUMNS / 2), y: baselineOn(35, 14), size: 14, weight: 700, muted: true, anchor: 'middle' };const BRACE_ROWS = ['   ###', '  ####', ' ###  ', ...Array<string>(9).fill(' ##   '), '###   ', '##    ', '###   ', ...Array<string>(9).fill(' ##   '), ' ###  ', '  ####', '   ###'];const BRACE = { top: 5, left: 3, columns: 6 };const openingBrace = placeCells(glyphCellsFromRows(BRACE_ROWS), BRACE);const closingBrace = openingBrace.map((cell) => ({ ...cell, column: COLUMNS - 1 - cell.column }));const FIELD_ROWS = [9, 14, 19, 24];const FIELDS = ['"company": "Mintlify",', '"uses": "brand context",', '"for": "branded docs sites",', '"sources": 3'];const CITED_ROW = 29;const citations = [0, 2, 4].map((step) => ({ column: BRACE.left + BRACE.columns + 5 + step, row: CITED_ROW, filled: true }));const bullets = FIELD_ROWS.map((row) => ({ column: BRACE.left + BRACE.columns + 3, row, filled: false }));const pictureOfCells = (cells: typeof openingBrace): StoryPicture => {	const states = cellStates(ANSWER_STORY_GRID, cells);	return { states, cells: cellsOfStates(ANSWER_STORY_GRID, states) };};export const BRACES = pictureOfCells([...openingBrace, ...closingBrace]);export const ANSWER = pictureOfCells([...openingBrace, ...closingBrace, ...bullets, ...citations]);export const FIELD_TEXTS: DotStoryText[] = FIELDS.map((text, index) => ({ text, x: x(BRACE.left + BRACE.columns + 5), y: baselineOn(FIELD_ROWS[index], 16), size: 16, weight: 700 }));export const CITED: DotStoryText = { text: '3 sources cited', x: x(BRACE.left + BRACE.columns + 12), y: baselineOn(CITED_ROW, 13), size: 13, weight: 700, muted: true };export const FIELD_COUNT = FIELDS.length;export const fieldAt = (row: number) =>	Math.max(		0,		FIELD_ROWS.findIndex((fieldRow, index) => row <= fieldRow || index === FIELD_ROWS.length - 1)	);/** The answer in its shape: what the picture shows before it plays, and instead of playing with reduced motion. */export const ANSWER_RESTING = { ...ANSWER, texts: [...FIELD_TEXTS, CITED] };

Reference: site src/components/sections/home/answer-story.tsx.