Type storyDesign system
VisualSite

Type story

Dot animation demonstrating font weights and color values.

npx context-ds add story-type
On the site

Usage

Use it for

Colors and typography on a site card. Plays only on screen; shows the bold specimen with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: dot-engine, story-parts

Example

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

Source

components/visuals/stories/type-story.tsx
'use client';import { TYPE_RESTING, TYPE_STORY_GRID } from '@/components/visuals/stories/type-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadTypeStory: LoadDotStory = async (stage) => {	const { createTypeStoryPlayer } = await import('@/components/visuals/stories/type-story-player');	return createTypeStoryPlayer(stage);};/** * The "Colors and typography" card's picture: a type specimen set from light to bold, then the site's colors read * one chip at a time. With reduced motion it shows the bold specimen. */export function TypeStory({ className }: { className?: string }) {	return <DotStory grid={TYPE_STORY_GRID} resting={TYPE_RESTING} load={loadTypeStory} className={className} />;}
components/visuals/stories/type-story-player.ts
import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { CHIPS_AT_REST, COLOR_CAPTIONS, ROLE_COUNT, SPECIMEN_BOLD, SPECIMEN_LIGHT, TYPE_CAPTION, TYPE_STORY_GRID, WEIGHTS, paintChips, paintSpecimen, weightDetail } from '@/components/visuals/stories/type-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 site's type and color, played as a loop: a specimen is set in each weight the site uses, light to bold, and * melts into the site's colors, a stack of chips pulled out one at a time as its hex value and role are read. */const MELT_SECONDS = 1.1;const WEIGHING = { startsAt: 0.7, everySeconds: 0.8 };const READING = { startsAt: 0.3, everySeconds: 1.1, textSeconds: 0.25 };const BOLD_AT = WEIGHING.startsAt + (WEIGHTS.length - 2) * WEIGHING.everySeconds;const stepAt = (heldSeconds: number, { startsAt, everySeconds }: { startsAt: number; everySeconds: number }, last: number) => (heldSeconds < startsAt ? -1 : Math.min(last, Math.floor((heldSeconds - startsAt) / everySeconds)));const weightAt = (heldSeconds: number) => stepAt(heldSeconds, WEIGHING, WEIGHTS.length - 2) + 1;export function createTypeStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	const [fontLabel, fontName, fontDetail] = TYPE_CAPTION;	return createBeatStoryPlayer(stage, {		grid: TYPE_STORY_GRID,		resting: { beat: 0, heldSeconds: BOLD_AT + 0.8 },		beats: [			{				picture: SPECIMEN_LIGHT,				departure: SPECIMEN_BOLD,				arrival: { kind: 'melts', seconds: MELT_SECONDS },				holdSeconds: BOLD_AT + 1.6,				animate: (states, heldSeconds) => void paintSpecimen(states, WEIGHTS[weightAt(heldSeconds)].stroke),				write(pen, heldSeconds) {					pen.text(fontLabel);					pen.text(fontName);					pen.text({ ...fontDetail, text: weightDetail(weightAt(heldSeconds)) });				},			},			{				...captionedBeat({ picture: CHIPS_AT_REST, caption: COLOR_CAPTIONS[0], holdSeconds: READING.startsAt + ROLE_COUNT * READING.everySeconds }),				// The last chip slides back in as the hold ends, so the stack melts away as it arrived.				animate: (states, heldSeconds) => void paintChips(states, heldSeconds > READING.startsAt + ROLE_COUNT * READING.everySeconds - 0.15 ? -1 : stepAt(heldSeconds, READING, ROLE_COUNT - 1)),				write(pen, heldSeconds) {					const reading = stepAt(heldSeconds, READING, ROLE_COUNT - 1);					if (reading < 0) return;					const written = clamp01((heldSeconds - READING.startsAt - reading * READING.everySeconds) / READING.textSeconds);					COLOR_CAPTIONS[reading].forEach((text, line) => pen.text(text, line === 0 ? 1 : written));				},			},		],	});}
components/visuals/stories/type-story-scenes.ts
import { storyCaption } from '@/components/visuals/dot-engine/story-caption';import { typeSpecimen } from '@/components/visuals/stories/style-guide-parts';import { DOT_ABSENT, DOT_FILLED, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, paintShape, rings, roundedBox, solid, solidWithSpeckle } from '@/components/visuals/dot-engine/dot-story-cells';/** * The "Colors and typography" card's pictures, in the brand dot grid: a type specimen that can be set from light to * bold, and the site's colors as a stack of chips from its darkest role to its lightest. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const TYPE_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = TYPE_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(TYPE_STORY_GRID, states) });const blank = () => new Uint8Array(COLUMNS * ROWS);const SPECIMEN = { origin: { column: 4, row: 10 }, size: 17 };/** The stroke of each weight the specimen is set in, lightest first, and what the site calls it. */export const WEIGHTS = [	{ stroke: 0.55, name: '400' },	{ stroke: 1, name: '500' },	{ stroke: 1.5, name: '600' },];/** The specimen set with strokes `stroke` dots either side of their line. */export function paintSpecimen(states: Uint8Array, stroke: number) {	const specimen = typeSpecimen(SPECIMEN.origin, SPECIMEN.size, stroke);	for (let cell = 0; cell < states.length; cell++) states[cell] = specimen(cell % COLUMNS, Math.floor(cell / COLUMNS)) ? DOT_FILLED : DOT_ABSENT;	return states;}export const SPECIMEN_LIGHT = pictureOfStates(paintSpecimen(blank(), WEIGHTS[0].stroke));export const SPECIMEN_BOLD = pictureOfStates(paintSpecimen(blank(), WEIGHTS[WEIGHTS.length - 1].stroke));const CHIP = { left: 3, right: 28, rows: 8, top: 5, every: 10, radius: 2, pull: 3 };// Copy the captions show (a sample site's colors, read out as hex), not colors this page paints.const SAMPLE_ROLE_HEXES = { text: '222326', primary: '5E6AD2', surface: 'F4F5F8' };// Three color roles, darkest to lightest, told apart by how many of their dots are solid.const ROLES = [	{ hex: SAMPLE_ROLE_HEXES.text, role: 'text', tone: solid },	{ hex: SAMPLE_ROLE_HEXES.primary, role: 'primary', tone: solidWithSpeckle },	{ hex: SAMPLE_ROLE_HEXES.surface, role: 'surface', tone: rings },];const chipBox = (index: number) => ({ left: CHIP.left, right: CHIP.right, top: CHIP.top + index * CHIP.every, bottom: CHIP.top + index * CHIP.every + CHIP.rows - 1 });/** The color chips, the one being read (none when negative) pulled out to the right of the stack. */export function paintChips(states: Uint8Array, reading: number) {	states.fill(DOT_ABSENT);	ROLES.forEach(({ tone }, index) => {		const box = chipBox(index);		const pulled = index === reading ? CHIP.pull : 0;		paintShape(TYPE_STORY_GRID, states, roundedBox({ ...box, left: box.left + pulled, right: box.right + pulled }, CHIP.radius), tone);	});	return states;}export const CHIPS_AT_REST = pictureOfStates(paintChips(blank(), -1));export const ROLE_COUNT = ROLES.length;const caption = (label: string, value: string, detail: string) => storyCaption(TYPE_STORY_GRID, 35, label, value, detail);export const TYPE_CAPTION = caption('font', 'Inter', 'weight 400, 16px / 1.5');export const weightDetail = (weight: number) => `weight ${WEIGHTS[weight].name}, 16px / 1.5`;export const COLOR_CAPTIONS = ROLES.map(({ hex, role }) => caption('color', `#${hex}`, `role: ${role}`));/** The bold specimen: what the picture shows before it plays, and instead of playing with reduced motion. */export const TYPE_RESTING = { ...SPECIMEN_BOLD, texts: [TYPE_CAPTION[0], TYPE_CAPTION[1], { ...TYPE_CAPTION[2], text: weightDetail(WEIGHTS.length - 1) }] };

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