Autofill storyDesign system
VisualSite

Autofill story

Dot animation of a domain populating a signup form.

npx context-ds add story-autofill
On the site

Usage

Use it for

Onboarding autofill 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-autofill.tsx
import { Card } from "@/components/ui/card";import { AutofillStory } from "@/components/visuals/stories/autofill-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]">        <AutofillStory />      </div>    </Card>  );}

Source

components/visuals/stories/autofill-story.tsx
'use client';import { AUTOFILL_RESTING, AUTOFILL_STORY_GRID } from '@/components/visuals/stories/autofill-story-scenes';import { DotStory, type LoadDotStory } from '@/components/visuals/dot-engine/dot-story';const loadAutofillStory: LoadDotStory = async (stage) => {	const { createAutofillStoryPlayer } = await import('@/components/visuals/stories/autofill-story-player');	return createAutofillStoryPlayer(stage);};/** * The "Autofill onboarding forms" card's picture: a domain is typed, a pointer clicks Auto-fill, and the signup form * fills itself field by field. With reduced motion it shows the form filled. */export function AutofillStory({ className }: { className?: string }) {	return <DotStory grid={AUTOFILL_STORY_GRID} resting={AUTOFILL_RESTING} load={loadAutofillStory} className={className} />;}
components/visuals/stories/autofill-story-player.ts
import { AUTOFILL_RESTING, AUTOFILL_RESTING_STATES, AUTOFILL_STORY_GRID, BUTTON, BUTTON_LABEL, BUTTON_PRESSED_CELLS, DOMAIN_TEXT, EMPTY_FIELD_CELLS, FORM_FIELDS, FORM_TITLE, FRAME_CELLS, filledCountText, type FormField } from '@/components/visuals/stories/autofill-story-scenes';import { DOT_FILLED } from '@/components/visuals/dot-engine/dot-morph-dots';import { cellIndexIn, cellState, cellStates, clamp01, hasRippleReached } from '@/components/visuals/dot-engine/dot-story-cells';import { createDotStoryPlayer, dotStoryFont, paintDotStoryCursor, paintDotStoryText, type DotStoryPlayer, type DotStoryStage, type DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * One signup, played as a loop: the domain is typed, a pointer comes up and clicks Auto-fill, and the form fills * itself field by field, each input's border inking in as its value is typed and its tick lighting when it is done. * Then the form clears for the next signup. */const DOMAIN_TYPING = { startsAt: 0.4, charactersPerSecond: 11 };const DOMAIN_TYPED_AT = DOMAIN_TYPING.startsAt + DOMAIN_TEXT.text.length / DOMAIN_TYPING.charactersPerSecond;const POINTER = { appearsAt: DOMAIN_TYPED_AT - 0.3, fadeSeconds: 0.25, travelsAt: DOMAIN_TYPED_AT, travelSeconds: 0.75 };const CLICK = { at: POINTER.travelsAt + POINTER.travelSeconds + 0.1, pressSeconds: 0.18, heldSeconds: 0.35, pressedScale: 0.82 };const POINTER_LEAVES = { at: CLICK.at + CLICK.heldSeconds + 0.1, seconds: 0.45 };const FIELD = { firstAt: CLICK.at + 0.3, borderSeconds: 0.3, typingDelay: 0.15, charactersPerSecond: 34, tickDelay: 0.1, tickSeconds: 0.2 };const fieldSchedule = FORM_FIELDS.reduce<{ startsAt: number; typingAt: number; typedAt: number; tickAt: number }[]>((schedule, field) => {	const previous = schedule[schedule.length - 1];	const startsAt = previous ? previous.typedAt + 0.05 : FIELD.firstAt;	const typingAt = startsAt + FIELD.typingDelay;	const typedAt = typingAt + field.value.text.length / FIELD.charactersPerSecond;	schedule.push({ startsAt, typingAt, typedAt, tickAt: typedAt + FIELD.tickDelay });	return schedule;}, []);const FILLED_AT = fieldSchedule[fieldSchedule.length - 1].tickAt + FIELD.tickSeconds;// Every field is filled and the pointer is long gone: the picture the static art shows.const RESTING_AT_SECONDS = FILLED_AT + 0.3;const CLEAR = { startsAt: FILLED_AT + 2.4, seconds: 0.4 };const LOOP_SECONDS = CLEAR.startsAt + CLEAR.seconds + 0.4;const CURSOR_GAP = 3;const CURSOR_LINGERS_SECONDS = 0.3;const { columns, rows, pitch } = AUTOFILL_STORY_GRID;const cellIndex = cellIndexIn(AUTOFILL_STORY_GRID);const FRAME_STATES = cellStates(AUTOFILL_STORY_GRID, FRAME_CELLS);const EMPTY_STATES = cellStates(AUTOFILL_STORY_GRID, [...FRAME_CELLS, ...EMPTY_FIELD_CELLS]);const INPUT_LEFT = FORM_FIELDS[0].border[0].column;const INPUT_COLUMNS = FORM_FIELDS[0].border[FORM_FIELDS[0].border.length - 1].column - INPUT_LEFT + 1;// The pointer's outline in grid units, measured from its tip.const POINTER_OUTLINE = [	[0, 0],	[0, 17],	[4.5, 13],	[7.5, 20],	[10.5, 18.8],	[7.5, 12.2],	[13, 12.2],] as const;const POINTER_STROKE = 2;const POINTER_OUTLINE_WIDTH = 1.2;// Drawn larger than a real cursor, so the dots inside it are big enough to read as the brand pattern.const POINTER_SIZE = 1.4;const POINTER_BOUNDS = { width: 13, height: 20 };const POINTER_DOTS = { pitch: 2.8, radius: 1 };const POINTER_FROM = { x: 34 * pitch, y: 27 * pitch };const POINTER_TO = { x: (BUTTON.left + BUTTON.columns / 2) * pitch, y: (BUTTON.top + BUTTON.rows / 2 + 0.3) * pitch };const POINTER_AWAY = { x: POINTER_TO.x + 3 * pitch, y: POINTER_TO.y + 6 * pitch };const easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);const between = (from: { x: number; y: number }, to: { x: number; y: number }, t: number) => ({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t });export function createAutofillStoryPlayer({ canvas, dotColor, surfaceColor, fontFamily, onFirstFrame }: DotStoryStage): DotStoryPlayer {	const states = new Uint8Array(columns * rows);	// The canvas draws with whatever is loaded at the time, so the fonts are asked for before any text is typed.	for (const text of AUTOFILL_RESTING.texts) void document.fonts.load(dotStoryFont(text, fontFamily), text.text);	const isButtonPressed = (seconds: number) => seconds >= CLICK.at && seconds < CLICK.at + CLICK.heldSeconds;	const paintField = (field: FormField, index: number, seconds: number) => {		const { startsAt, tickAt } = fieldSchedule[index];		const inked = (seconds - startsAt) / FIELD.borderSeconds;		for (const cell of field.border) {			if (hasRippleReached((cell.column - INPUT_LEFT) / INPUT_COLUMNS, inked)) states[cellIndex(cell)] = DOT_FILLED;		}		if (seconds >= fieldSchedule[index].typingAt) for (const cell of field.mark) states[cellIndex(cell)] = cellState(cell);		const ticked = (seconds - tickAt) / FIELD.tickSeconds;		field.tick.forEach((cell, step) => {			if (hasRippleReached(step / field.tick.length, ticked)) states[cellIndex(cell)] = DOT_FILLED;		});	};	const statesAt = (seconds: number) => {		if (seconds >= CLEAR.startsAt) return EMPTY_STATES;		states.set(EMPTY_STATES);		if (isButtonPressed(seconds)) for (const cell of BUTTON_PRESSED_CELLS) states[cellIndex(cell)] = DOT_FILLED;		FORM_FIELDS.forEach((field, index) => paintField(field, index, seconds));		for (const cell of FRAME_CELLS) states[cellIndex(cell)] = FRAME_STATES[cellIndex(cell)];		return states;	};	const typedFor = (text: DotStoryText, secondsTyping: number, charactersPerSecond: number): DotStoryText => ({ ...text, text: text.text.slice(0, Math.max(0, Math.floor(secondsTyping * charactersPerSecond))) });	const paintTypingCursor = (context: CanvasRenderingContext2D, typed: DotStoryText, presence: number) => {		context.font = dotStoryFont(typed, fontFamily);		paintDotStoryCursor(context, typed.x + context.measureText(typed.text).width + CURSOR_GAP, typed, presence);	};	const paintText = (context: CanvasRenderingContext2D, seconds: number) => {		const presence = 1 - clamp01((seconds - CLEAR.startsAt) / CLEAR.seconds);		paintDotStoryText(context, fontFamily, FORM_TITLE);		const domain = typedFor(DOMAIN_TEXT, seconds - DOMAIN_TYPING.startsAt, DOMAIN_TYPING.charactersPerSecond);		paintDotStoryText(context, fontFamily, domain, presence);		if (seconds >= DOMAIN_TYPING.startsAt && seconds < DOMAIN_TYPED_AT + CURSOR_LINGERS_SECONDS) paintTypingCursor(context, domain, 1);		// Pressed, the button is solid dots, so its label is cut out of them in the card's color.		paintDotStoryText(context, fontFamily, isButtonPressed(seconds) ? { ...BUTTON_LABEL, color: surfaceColor } : BUTTON_LABEL);		let filledCount = 0;		FORM_FIELDS.forEach((field, index) => {			const { typingAt, typedAt, tickAt } = fieldSchedule[index];			paintDotStoryText(context, fontFamily, field.label);			const value = typedFor(field.value, seconds - typingAt, FIELD.charactersPerSecond);			paintDotStoryText(context, fontFamily, value, presence);			const nextStartsAt = fieldSchedule[index + 1]?.startsAt ?? typedAt + CURSOR_LINGERS_SECONDS;			if (seconds >= typingAt && seconds < nextStartsAt) paintTypingCursor(context, value, 1);			if (seconds >= tickAt + FIELD.tickSeconds && seconds < CLEAR.startsAt) filledCount++;		});		paintDotStoryText(context, fontFamily, filledCountText(filledCount));	};	const pointerAt = (seconds: number) => {		if (seconds < POINTER.appearsAt || seconds >= POINTER_LEAVES.at + POINTER_LEAVES.seconds) return null;		if (seconds >= POINTER_LEAVES.at) {			const left = (seconds - POINTER_LEAVES.at) / POINTER_LEAVES.seconds;			return { ...between(POINTER_TO, POINTER_AWAY, easeInOut(left)), opacity: 1 - left, scale: 1 };		}		const traveled = easeInOut(clamp01((seconds - POINTER.travelsAt) / POINTER.travelSeconds));		const pressed = Math.sin(Math.PI * clamp01((seconds - CLICK.at) / CLICK.pressSeconds));		return { ...between(POINTER_FROM, POINTER_TO, traveled), opacity: clamp01((seconds - POINTER.appearsAt) / POINTER.fadeSeconds), scale: 1 - (1 - CLICK.pressedScale) * pressed };	};	const paintPointer = (context: CanvasRenderingContext2D, seconds: number) => {		const pointer = pointerAt(seconds);		if (!pointer) return;		context.save();		context.translate(pointer.x, pointer.y);		context.scale(pointer.scale * POINTER_SIZE, pointer.scale * POINTER_SIZE);		context.globalAlpha = pointer.opacity;		const outline = new Path2D();		POINTER_OUTLINE.forEach(([pointX, pointY], index) => (index === 0 ? outline.moveTo(pointX, pointY) : outline.lineTo(pointX, pointY)));		outline.closePath();		context.lineJoin = 'round';		context.lineWidth = POINTER_STROKE * 2;		context.strokeStyle = surfaceColor;		context.stroke(outline);		context.fillStyle = surfaceColor;		context.fill(outline);		// The pointer is filled with the brand dot pattern rather than solid, so it reads as part of the picture.		context.save();		context.clip(outline);		context.fillStyle = dotColor;		context.beginPath();		for (let y = POINTER_DOTS.pitch / 2; y < POINTER_BOUNDS.height; y += POINTER_DOTS.pitch) {			for (let x = POINTER_DOTS.pitch / 2; x < POINTER_BOUNDS.width; x += POINTER_DOTS.pitch) {				context.moveTo(x + POINTER_DOTS.radius, y);				context.arc(x, y, POINTER_DOTS.radius, 0, Math.PI * 2);			}		}		context.fill();		context.restore();		context.lineWidth = POINTER_OUTLINE_WIDTH;		context.strokeStyle = dotColor;		context.stroke(outline);		context.restore();	};	return createDotStoryPlayer({		canvas,		dotColor,		surfaceColor,		onFirstFrame,		grid: AUTOFILL_STORY_GRID,		resting: { states: AUTOFILL_RESTING_STATES, atSeconds: RESTING_AT_SECONDS },		frameAt(storySeconds) {			const seconds = storySeconds % LOOP_SECONDS;			return {				states: statesAt(seconds),				paintAbove(context) {					paintText(context, seconds);					paintPointer(context, seconds);				},			};		},	});}
components/visuals/stories/autofill-story-scenes.ts
import { glyphCellsFromRows, type GlyphCell } from '@/components/visuals/dot-engine/dot-glyph-cells';import type { DotGrid } from '@/components/visuals/dot-engine/dot-morph-dots';import { cellStates, fillCells, outlineCells, type Region } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryText } from '@/components/visuals/dot-engine/dot-story-player';/** * The "Autofill onboarding forms" card's picture, in the brand dot grid: a domain goes into the field at the top, and * pressing Auto-fill fills the signup form below it, one field at a time. */// The ring pattern behind the picture already keeps clear of it, so the dots need no halo of their own.export const AUTOFILL_STORY_GRID: DotGrid = { columns: 58, rows: 38, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 0 };const { columns: COLUMNS, pitch: PITCH } = AUTOFILL_STORY_GRID;const x = (column: number) => column * PITCH;const y = (row: number) => row * PITCH;// Doto sits a third of its size below the middle of the row it is set on.const baselineOn = (row: number, size: number) => y(row + 0.5) + size / 3;const LABEL_SIZE = 14;const VALUE_SIZE = 15;const DOMAIN_FIELD: Region = { left: 0, top: 0, columns: COLUMNS, rows: 7 };const DOMAIN_ICON = { column: 2, row: 3 };export const BUTTON: Region = { left: 43, top: 1, columns: 13, rows: 5 };export const BUTTON_INSIDE: Region = { left: BUTTON.left + 1, top: BUTTON.top + 1, columns: BUTTON.columns - 2, rows: BUTTON.rows - 2 };const FORM: Region = { left: 0, top: 8, columns: COLUMNS, rows: 30 };const HEADER_ROW = 10;const INPUT_LEFT = 16;const INPUT_COLUMNS = 33;const INPUT_ROWS = 4;const FIRST_INPUT_TOP = 12;const INPUT_SPACING = 5;const TICK_LEFT = 52;const TICK_ROWS = ['   #', '# # ', ' #  '];const MARK_LEFT = INPUT_LEFT + 2;const VALUE_AFTER_MARK = INPUT_LEFT + 5;/** The parts that never change: the domain field with its Auto-fill button, and the form's panel. */export const FRAME_CELLS: GlyphCell[] = [...outlineCells(DOMAIN_FIELD), { ...DOMAIN_ICON, filled: false }, ...outlineCells(BUTTON), ...outlineCells(FORM)];export interface FormField {	/** The input's border, left to right, so it can fill in from where the typing starts. */	border: GlyphCell[];	tick: GlyphCell[];	/** A swatch or logo shown before the value, for the fields that have one. */	mark: GlyphCell[];	label: DotStoryText;	value: DotStoryText;}function formField(label: string, value: string, markRows: string[] | null, index: number): FormField {	const top = FIRST_INPUT_TOP + index * INPUT_SPACING;	const textRow = top + 1.5;	const border = outlineCells({ left: INPUT_LEFT, top, columns: INPUT_COLUMNS, rows: INPUT_ROWS }).sort((a, b) => a.column - b.column);	const tick = glyphCellsFromRows(TICK_ROWS).map((cell) => ({ ...cell, column: cell.column + TICK_LEFT, row: cell.row + top + 1 }));	const mark = markRows ? glyphCellsFromRows(markRows).map((cell) => ({ ...cell, column: cell.column + MARK_LEFT, row: cell.row + top + 1 })) : [];	return {		border,		tick,		mark,		label: { text: label, x: x(2), y: baselineOn(textRow, LABEL_SIZE), size: LABEL_SIZE, weight: 700, muted: true },		value: { text: value, x: x(markRows ? VALUE_AFTER_MARK : MARK_LEFT), y: baselineOn(textRow, VALUE_SIZE), size: VALUE_SIZE, weight: 700 },	};}// Copy the form shows (the color autofill found), not a color this page paints.const SAMPLE_BRAND_HEX = '5E6AD2';export const FORM_FIELDS: FormField[] = [	formField('Company', 'Linear', null, 0),	formField('Industry', 'Developer Tools', null, 1),	formField('Brand color', `#${SAMPLE_BRAND_HEX}`, ['##', '##'], 2),	formField('Description', 'Issue tracking for modern teams', null, 3),	formField('Logo', 'linear-logo.svg', ['#o', 'o#'], 4),];export const DOMAIN_TEXT: DotStoryText = { text: 'linear.app', x: x(4.5), y: baselineOn(DOMAIN_ICON.row, 20), size: 20, weight: 700 };export const BUTTON_LABEL: DotStoryText = { text: 'Auto-fill', x: x(BUTTON.left + BUTTON.columns / 2), y: baselineOn(BUTTON.top + 2, LABEL_SIZE), size: LABEL_SIZE, weight: 800, anchor: 'middle' };export const FORM_TITLE: DotStoryText = { text: 'Signup form', x: x(2), y: baselineOn(HEADER_ROW, LABEL_SIZE), size: LABEL_SIZE, weight: 700, muted: true };export const FILLED_COUNT: DotStoryText = { text: `${FORM_FIELDS.length} fields filled`, x: x(COLUMNS - 2), y: baselineOn(HEADER_ROW, LABEL_SIZE), size: LABEL_SIZE, weight: 800, anchor: 'end' };export const filledCountText = (count: number): DotStoryText => ({ ...FILLED_COUNT, text: `${count} fields filled`, muted: count === 0 });/** The form's cells before anything is filled: the inputs and their ticks, all rings. */export const EMPTY_FIELD_CELLS: GlyphCell[] = FORM_FIELDS.flatMap((field) => [...field.border, ...field.tick].map((cell) => ({ ...cell, filled: false })));export const BUTTON_PRESSED_CELLS = fillCells(BUTTON_INSIDE, true);const FILLED_FIELD_CELLS = FORM_FIELDS.flatMap((field) => [...field.border, ...field.tick, ...field.mark]);/** Everything filled: what the picture shows before it plays, and instead of playing with reduced motion. */export const AUTOFILL_RESTING = {	cells: [...FRAME_CELLS, ...FILLED_FIELD_CELLS],	texts: [DOMAIN_TEXT, BUTTON_LABEL, FORM_TITLE, FILLED_COUNT, ...FORM_FIELDS.flatMap((field) => [field.label, field.value])],};export const AUTOFILL_RESTING_STATES = cellStates(AUTOFILL_STORY_GRID, AUTOFILL_RESTING.cells);

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