VisualSite
Identity story
Dot animation of domains, email addresses, names, and tickers resolving to a brand.
$ npx context-ds add story-identityUsage
Use it for
Resolving an identity to a brand on a site card. Plays only on screen; shows the seal with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: dot-engine, story-freshness
Example
examples/story-identity.tsx
import { Card } from "@/components/ui/card";import { IdentityStory } from "@/components/visuals/stories/identity-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]"> <IdentityStory /> </div> </Card> );}Source
components/visuals/stories/identity-story.tsx
'use client';import { IDENTITY_RESTING, IDENTITY_STORY_GRID } from '@/components/visuals/stories/identity-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadIdentityStory: LoadDotStory = async (stage) => { const { createIdentityStoryPlayer } = await import('@/components/visuals/stories/identity-story-player'); return createIdentityStoryPlayer(stage);};/** * The "Resolve an identity" card's picture: a domain, an email, a name and a ticker melt into one another, then into * the seal of the brand they resolve to. With reduced motion it shows the seal. */export function IdentityStory({ className }: { className?: string }) { return <DotStory grid={IDENTITY_STORY_GRID} resting={IDENTITY_RESTING} load={loadIdentityStory} className={className} />;}components/visuals/stories/identity-story-player.ts
import { CAPTIONS, CHART_ARRIVES_DRAWN, CHART_DRAWN, CHART_STARTED, DOMAIN_GLOBE, ENVELOPE_PICTURE, IDENTITY_STORY_GRID, NAME_BADGE, SEAL_TICKED, SEAL_UNTICKED, paintChart, paintSeal } from '@/components/visuals/stories/identity-story-scenes';import { captionedBeat } from '@/components/visuals/dot-engine/story-caption';import { createBeatStoryPlayer } from '@/components/visuals/dot-engine/dot-story-beats';import { clamp01, easeOut } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One lookup, played as a loop: the four identifiers a brand can be found by melt into one another, a domain's * globe, an email's envelope, a name badge and a ticker's chart drawing its line; then the chart melts into a seal, * and its tick is cut as the brand they all resolve to is named. */const IDENTIFIER_HOLD_SECONDS = 1.7;const DRAWING = { startsAt: 0.2, seconds: 1 };const TICKING = { startsAt: 0.3, seconds: 0.5 };export function createIdentityStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: IDENTITY_STORY_GRID, resting: { beat: 4, heldSeconds: 2 }, beats: [ captionedBeat({ picture: DOMAIN_GLOBE, caption: CAPTIONS.domain, holdSeconds: IDENTIFIER_HOLD_SECONDS, isTyped: true }), captionedBeat({ picture: ENVELOPE_PICTURE, caption: CAPTIONS.email, holdSeconds: IDENTIFIER_HOLD_SECONDS, isTyped: true }), captionedBeat({ picture: NAME_BADGE, caption: CAPTIONS.name, holdSeconds: IDENTIFIER_HOLD_SECONDS, isTyped: true }), captionedBeat({ picture: CHART_STARTED, departure: CHART_DRAWN, caption: CAPTIONS.ticker, holdSeconds: IDENTIFIER_HOLD_SECONDS + 0.3, isTyped: true, animate: (states, heldSeconds) => void paintChart(states, CHART_ARRIVES_DRAWN + (1 - CHART_ARRIVES_DRAWN) * easeOut(clamp01((heldSeconds - DRAWING.startsAt) / DRAWING.seconds))), }), captionedBeat({ picture: SEAL_UNTICKED, departure: SEAL_TICKED, caption: CAPTIONS.brand, holdSeconds: 3.4, animate: (states, heldSeconds) => void paintSeal(states, clamp01((heldSeconds - TICKING.startsAt) / TICKING.seconds)), }), ], });}components/visuals/stories/identity-story-scenes.ts
import { GLOBE_AT_REST } from '@/components/visuals/stories/freshness-story-scenes';import { storyCaption } from '@/components/visuals/dot-engine/story-caption';import { DOT_ABSENT, DOT_FILLED, DOT_HOLLOW, type DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import type { StoryPicture } from '@/components/visuals/dot-engine/dot-story-beats';import { cellsOfStates, distanceToSegment, paintShape, solidWithSpeckle, type DotShape, type GridPoint } from '@/components/visuals/dot-engine/dot-story-cells';/** * The "Resolve an identity" card's pictures, in the brand dot grid: the four things a brand can be looked up by, a * domain's globe, an email's envelope, a name badge and a ticker's chart, and the seal that says which brand it is. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const IDENTITY_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, rows: ROWS } = IDENTITY_STORY_GRID;const pictureOfStates = (states: Uint8Array): StoryPicture => ({ states, cells: cellsOfStates(IDENTITY_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 is.const CENTER = 16;const LINE_WIDTH = 0.6;export const DOMAIN_GLOBE = GLOBE_AT_REST;const ENVELOPE = { left: 3, right: 29, top: 8, bottom: 29, flapTo: { column: CENTER, row: 20 } };const isEnvelope: DotShape = (column, row) => between(column, ENVELOPE.left, ENVELOPE.right) && between(row, ENVELOPE.top, ENVELOPE.bottom);// How far down the flap's edge is in this column: it runs from each top corner to a point in the middle.const flapEdge = (column: number) => ENVELOPE.top + ((ENVELOPE.flapTo.row - ENVELOPE.top) * Math.min(column - ENVELOPE.left, ENVELOPE.right - column)) / (CENTER - ENVELOPE.left);export const ENVELOPE_PICTURE = pictureOfStates( paintShape(IDENTITY_STORY_GRID, blank(), isEnvelope, (column, row) => { const belowFlap = row - flapEdge(column); if (Math.abs(belowFlap) < 0.8) return DOT_FILLED; return belowFlap < 0 ? solidWithSpeckle(column, row) : DOT_HOLLOW; }));const BADGE = { left: 4, right: 28, top: 7, bottom: 31, clip: { left: 13, right: 19, top: 3 }, header: 13, name: { top: 18, bottom: 21, left: 8, right: 24 }, line: { row: 25, left: 10, right: 22 } };const isBadge: DotShape = (column, row) => (between(column, BADGE.left, BADGE.right) && between(row, BADGE.top, BADGE.bottom)) || (between(column, BADGE.clip.left, BADGE.clip.right) && between(row, BADGE.clip.top, BADGE.top));// A name badge on its clip: a solid band across the top, the name in solid dots, a quieter line under it.export const NAME_BADGE = pictureOfStates( paintShape(IDENTITY_STORY_GRID, blank(), isBadge, (column, row) => { if (row <= BADGE.header) return DOT_FILLED; if (between(row, BADGE.name.top, BADGE.name.bottom) && between(column, BADGE.name.left, BADGE.name.right)) return DOT_FILLED; if (row === BADGE.line.row && between(column, BADGE.line.left, BADGE.line.right)) return DOT_ABSENT; return DOT_HOLLOW; }));const CHART = { left: 4, right: 29, top: 5, bottom: 31, lineWidth: 1 };const PRICES: GridPoint[] = [ { column: 5, row: 27 }, { column: 10, row: 21 }, { column: 14, row: 24 }, { column: 20, row: 14 }, { column: 24, row: 17 }, { column: 29, row: 7 },];const fromPriceLine = (column: number, row: number) => Math.min(...PRICES.slice(1).map((point, index) => distanceToSegment(column, row, PRICES[index], point)));const priceAt = (column: number) => { const to = Math.max(1, PRICES.findIndex((point) => point.column >= column)); const from = PRICES[to - 1]; return from.row + ((PRICES[to].row - from.row) * (column - from.column)) / (PRICES[to].column - from.column);};/** A ticker's chart with `drawn` of its price line in, from the left: two axes, the line, and rings filling under it. */export function paintChart(states: Uint8Array, drawn: number) { const drawnTo = CHART.left + drawn * (CHART.right - CHART.left); for (let row = 0; row < ROWS; row++) { for (let column = 0; column < COLUMNS / 2 + 2; column++) { const cell = row * COLUMNS + column; const isAxis = (column === CHART.left && between(row, CHART.top, CHART.bottom)) || (row === CHART.bottom && between(column, CHART.left, CHART.right)); if (isAxis) states[cell] = DOT_FILLED; else if (column <= CHART.left || column > Math.min(drawnTo, CHART.right) || row >= CHART.bottom) states[cell] = DOT_ABSENT; else if (fromPriceLine(column, row) < CHART.lineWidth) states[cell] = DOT_FILLED; else states[cell] = row > priceAt(column) ? DOT_HOLLOW : DOT_ABSENT; } } return states;}/** How much of the price line is already in when the chart arrives; an axis alone has too little in it to melt into. */export const CHART_ARRIVES_DRAWN = 0.35;export const CHART_STARTED = pictureOfStates(paintChart(blank(), CHART_ARRIVES_DRAWN));export const CHART_DRAWN = pictureOfStates(paintChart(blank(), 1));const SEAL = { center: { column: CENTER, row: 19 }, radius: 12, scallop: 1.4, scallops: 8, tick: { from: { column: 10, row: 19.5 }, corner: { column: 14.5, row: 24 }, to: { column: 22.5, row: 14 }, width: 1.3 } };const isSeal: DotShape = (column, row) => { const across = column - SEAL.center.column; const down = row - SEAL.center.row; return Math.hypot(across, down) <= SEAL.radius + SEAL.scallop * Math.cos(SEAL.scallops * Math.atan2(down, across));};const isTick = (column: number, row: number) => Math.min(distanceToSegment(column, row, SEAL.tick.from, SEAL.tick.corner), distanceToSegment(column, row, SEAL.tick.corner, SEAL.tick.to)) < SEAL.tick.width;/** The seal, with `ticked` of its tick cut out of it in rings, from the tick's short stroke to the end of its long one. */export function paintSeal(states: Uint8Array, ticked: number) { const tickedTo = SEAL.tick.from.column - 2 + ticked * (SEAL.tick.to.column + 4 - SEAL.tick.from.column); return paintShape(IDENTITY_STORY_GRID, states, isSeal, (column, row) => { if (isTick(column, row) && column <= tickedTo) return DOT_HOLLOW; return DOT_FILLED; });}export const SEAL_UNTICKED = pictureOfStates(paintSeal(blank(), 0));export const SEAL_TICKED = pictureOfStates(paintSeal(blank(), 1));const caption = (label: string, value: string, detail: string) => storyCaption(IDENTITY_STORY_GRID, 34, label, value, detail);export const CAPTIONS = { domain: caption('look up by', 'domain', 'shopify.com'), email: caption('or by', 'email', 'ana@shopify.com'), name: caption('or by', 'name', '"Shopify"'), ticker: caption('or by', 'ticker', 'SHOP'), brand: caption('all four resolve to', 'Shopify', 'one brand profile'),};/** The resolved brand: what the picture shows before it plays, and instead of playing with reduced motion. */export const IDENTITY_RESTING = { ...SEAL_TICKED, texts: CAPTIONS.brand };Reference: site src/components/sections/products/identity-story.tsx.