Glossary hero storyDesign system
VisualSite

Glossary hero story

Dot animation of glossary entries and lookup, with captions.

npx context-ds add story-glossary-hero
On the site

Usage

Use it for

The glossary page hero on the site. Plays only on screen; shows the book with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: story-parts, dot-engine

Example

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

Source

components/visuals/stories/glossary-hero-story.tsx
'use client';import { GLOSSARY_RESTING } from '@/components/visuals/stories/glossary-hero-story-scenes';import { HERO_STORY_GRID } from '@/components/visuals/stories/hero-story-kit';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadGlossaryHeroStory: LoadDotStory = async (stage) => {	const { createGlossaryHeroStoryPlayer } = await import('@/components/visuals/stories/glossary-hero-story-player');	return createGlossaryHeroStoryPlayer(stage);};/** * The glossary hero's picture: an open book read line by line, then one entry looked up term after term. With * reduced motion it shows the book. */export function GlossaryHeroStory({ className }: { className?: string }) {	return <DotStory grid={HERO_STORY_GRID} resting={GLOSSARY_RESTING} load={loadGlossaryHeroStory} className={className} />;}
components/visuals/stories/glossary-hero-story-player.ts
import { BOOK_CAPTION, BOOK_LINE_COUNT, BOOK_UNREAD, ENTRIES, ENTRY_CAPTIONS, TERMS, paintBook, paintEntry } from '@/components/visuals/stories/glossary-hero-story-scenes';import { HERO_STORY_GRID, stepAt } from '@/components/visuals/stories/hero-story-kit';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';/** * The glossary, played as a loop: a line of the open book is read at a time, then the book melts into a single * entry, and one term after another is looked up on it. */const READING = { startsAt: 0.4, everySeconds: 0.2 };const READ_AT = READING.startsAt + BOOK_LINE_COUNT * READING.everySeconds;const LOOKING_UP = { startsAt: 0, everySeconds: 1.5 };const TYPING = { startsAfter: 0.15, charactersPerSecond: 14 };const lastTerm = TERMS.length - 1;// Past the last line nothing is being read, so the book leaves as it arrived.const readingAt = (heldSeconds: number) => {	const line = stepAt(heldSeconds, READING, BOOK_LINE_COUNT);	return line === BOOK_LINE_COUNT ? -1 : line;};export function createGlossaryHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: HERO_STORY_GRID,		resting: { beat: 0, heldSeconds: READ_AT + 0.5 },		beats: [			captionedBeat({ picture: BOOK_UNREAD, caption: BOOK_CAPTION, holdSeconds: READ_AT + 1.2, isTyped: true, animate: (states, heldSeconds) => void paintBook(states, readingAt(heldSeconds)) }),			{				...captionedBeat({ picture: ENTRIES[0], departure: ENTRIES[lastTerm], caption: ENTRY_CAPTIONS[0], holdSeconds: TERMS.length * LOOKING_UP.everySeconds + 0.6, animate: (states, heldSeconds) => void paintEntry(states, stepAt(heldSeconds, LOOKING_UP, lastTerm)) }),				write(pen, heldSeconds) {					const term = stepAt(heldSeconds, LOOKING_UP, lastTerm);					const [label, word, detail] = ENTRY_CAPTIONS[term];					pen.text(label);					pen.type(word, heldSeconds, { startsAt: term * LOOKING_UP.everySeconds + TYPING.startsAfter, charactersPerSecond: TYPING.charactersPerSecond });					pen.text(detail);				},			},		],	});}
components/visuals/stories/glossary-hero-story-scenes.ts
import { HERO_STORY_GRID, between, blankScreen, heroCaption, pictureOfStates } from '@/components/visuals/stories/hero-story-kit';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW } from '@/components/visuals/dot-engine/dot-morph-dots';import { paintShape, roundedBox, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** The glossary hero's pictures, in the brand dot grid: an open book being read, and one of its entries up close. */const PAGES: DotBox[] = [	{ left: 3, right: 15, top: 6, bottom: 31 },	{ left: 17, right: 29, top: 6, bottom: 31 },];const PAGE_LINE = { firstRow: 10, every: 3, perPage: 7, inset: 2 };// How far short of the page's edge each line stops, so the two pages read as text rather than ruled paper.const LINE_SHORTFALLS = [0, 2, 0, 4, 1, 0, 5];export const BOOK_LINE_COUNT = PAGES.length * PAGE_LINE.perPage;/** The book with the line being read (none when negative) solid, and the rest rings. Lines run down one page, then the next. */export function paintBook(states: Uint8Array, reading: number) {	PAGES.forEach((page, pageIndex) => {		paintShape(HERO_STORY_GRID, states, roundedBox(page, 1), (column, row) => {			const line = (row - PAGE_LINE.firstRow) / PAGE_LINE.every;			if (!Number.isInteger(line) || !between(line, 0, PAGE_LINE.perPage - 1)) return DOT_ABSENT;			if (!between(column, page.left + PAGE_LINE.inset, page.right - PAGE_LINE.inset - LINE_SHORTFALLS[line])) return DOT_ABSENT;			return pageIndex * PAGE_LINE.perPage + line === reading ? DOT_FILLED : DOT_HOLLOW;		});	});	return states;}export const BOOK_UNREAD = pictureOfStates(paintBook(blankScreen(), -1));const ENTRY: DotBox = { left: 3, right: 29, top: 7, bottom: 30 };const ENTRY_LEFT = 6;const TERM_ROWS = [10, 11];const DEFINITION = [	{ row: 15, right: 26 },	{ row: 18, right: 23 },	{ row: 21, right: 25 },	{ row: 24, right: 21 },	{ row: 27, right: 16 },];/** The terms looked up, and how many dots wide each sets on the entry. */export const TERMS = [	{ word: 'crawling', dots: 12 },	{ word: 'HTTP', dots: 7 },	{ word: 'APIs', dots: 7 },	{ word: 'brands', dots: 10 },];/** The entry for the term at `index`: the term set solid, its definition in rings beneath. */export function paintEntry(states: Uint8Array, index: number) {	return paintShape(HERO_STORY_GRID, states, roundedBox(ENTRY, 2), (column, row) => {		if (TERM_ROWS.includes(row)) return between(column, ENTRY_LEFT, ENTRY_LEFT + TERMS[index].dots - 1) ? DOT_FILLED : DOT_ABSENT;		const line = DEFINITION.find((candidate) => candidate.row === row);		return line && between(column, ENTRY_LEFT, line.right) ? DOT_HOLLOW : DOT_ABSENT;	});}export const ENTRIES = TERMS.map((_, index) => pictureOfStates(paintEntry(blankScreen(), index)));export const BOOK_CAPTION = heroCaption('the glossary', 'A to Z', 'plain-English definitions');export const ENTRY_CAPTIONS = TERMS.map(({ word }) => heroCaption('look up', word, 'and what it means'));/** The open book: what the picture shows before it plays, and instead of playing with reduced motion. */export const GLOSSARY_RESTING = { ...BOOK_UNREAD, texts: BOOK_CAPTION };

Reference: site src/components/sections/page-heroes/glossary-hero-story.tsx.