VisualSite
Type story
Dot animation demonstrating font weights and color values.
$ npx context-ds add story-typeUsage
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.