VisualSite
Compare hero story
Dot animation of feature and benchmark comparisons, with captions.
$ npx context-ds add story-compare-heroUsage
Use it for
A comparison page hero on the site. Plays only on screen; shows the finished feature table with reduced motion.
Avoid
- Stories as the main content of a page
Dependencies: story-parts, dot-engine
Example
examples/story-compare-hero.tsx
import { Card } from "@/components/ui/card";import { CompareHeroStory } from "@/components/visuals/stories/compare-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]"> <CompareHeroStory /> </div> </Card> );}Source
components/visuals/stories/compare-hero-story.tsx
'use client';import { COMPARE_RESTING } from '@/components/visuals/stories/compare-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 loadCompareHeroStory: LoadDotStory = async (stage) => { const { createCompareHeroStoryPlayer } = await import('@/components/visuals/stories/compare-hero-story-player'); return createCompareHeroStoryPlayer(stage);};/** * The compare hero's picture: two tools checked feature by feature, then their bars rising side by side. With * reduced motion it shows the finished feature table. */export function CompareHeroStory({ className }: { className?: string }) { return <DotStory grid={HERO_STORY_GRID} resting={COMPARE_RESTING} load={loadCompareHeroStory} className={className} />;}components/visuals/stories/compare-hero-story-player.ts
import { BARS_FLAT, BARS_GROWN, COMPARE_CAPTIONS, FEATURES_CHECKED, FEATURES_UNCHECKED, FEATURE_COUNT, paintBars, paintFeatures } from '@/components/visuals/stories/compare-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 { clamp01, easeOut } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One comparison, played as a loop: the two tools are checked against each feature in turn, then the table melts * into pairs of bars that rise to show where each one comes out ahead. */const CHECKING = { startsAt: 0.5, everySeconds: 0.45 };const CHECKED_AT = CHECKING.startsAt + (FEATURE_COUNT - 1) * CHECKING.everySeconds;const RISING = { startsAt: 0.3, seconds: 1.4 };export function createCompareHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer { return createBeatStoryPlayer(stage, { grid: HERO_STORY_GRID, resting: { beat: 0, heldSeconds: CHECKED_AT + 0.8 }, beats: [ captionedBeat({ picture: FEATURES_UNCHECKED, departure: FEATURES_CHECKED, caption: COMPARE_CAPTIONS.features, holdSeconds: CHECKED_AT + 1.8, isTyped: true, animate: (states, heldSeconds) => void paintFeatures(states, stepAt(heldSeconds, CHECKING, FEATURE_COUNT - 1) + 1), }), captionedBeat({ picture: BARS_FLAT, departure: BARS_GROWN, caption: COMPARE_CAPTIONS.pricing, holdSeconds: RISING.startsAt + RISING.seconds + 1.8, isTyped: true, animate: (states, heldSeconds) => void paintBars(states, easeOut(clamp01((heldSeconds - RISING.startsAt) / RISING.seconds))), }), ], });}components/visuals/stories/compare-hero-story-scenes.ts
import { HERO_STORY_GRID, blankScreen, cellAt, 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, rings, roundedBox, solid, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** * The compare hero's pictures, in the brand dot grid: two tools checked against a list of features, and the same two * as pairs of bars. Context.dev is the solid one of each pair, the other tool the one in rings. */const OURS: DotBox = { left: 13, right: 19, top: 4, bottom: 7 };const THEIRS: DotBox = { left: 23, right: 29, top: 4, bottom: 7 };const FEATURE_LEFT = 3;const FEATURES = [ { row: 12, right: 9, theyHaveIt: true }, { row: 17, right: 7, theyHaveIt: false }, { row: 22, right: 10, theyHaveIt: true }, { row: 27, right: 8, theyHaveIt: false }, { row: 32, right: 9, theyHaveIt: false },];export const FEATURE_COUNT = FEATURES.length;const MARK_REACH = 1;function paintMark(states: Uint8Array, center: number, row: number, state: number) { for (let down = -MARK_REACH; down <= MARK_REACH; down++) { for (let across = -MARK_REACH; across <= MARK_REACH; across++) states[cellAt(center + across, row + down)] = state; }}/** The feature table with its first `checked` rows filled in: a block where a tool has the feature, a dash where not. */export function paintFeatures(states: Uint8Array, checked: number) { states.fill(DOT_ABSENT); paintShape(HERO_STORY_GRID, states, roundedBox(OURS, 1), solid); paintShape(HERO_STORY_GRID, states, roundedBox(THEIRS, 1), rings); const oursCenter = (OURS.left + OURS.right) / 2; const theirsCenter = (THEIRS.left + THEIRS.right) / 2; FEATURES.slice(0, checked).forEach(({ row, right, theyHaveIt }) => { for (let column = FEATURE_LEFT; column <= right; column++) states[cellAt(column, row)] = DOT_HOLLOW; paintMark(states, oursCenter, row, DOT_FILLED); if (theyHaveIt) paintMark(states, theirsCenter, row, DOT_HOLLOW); else for (let across = -MARK_REACH; across <= MARK_REACH; across++) states[cellAt(theirsCenter + across, row)] = DOT_HOLLOW; }); return states;}export const FEATURES_UNCHECKED = pictureOfStates(paintFeatures(blankScreen(), 0));export const FEATURES_CHECKED = pictureOfStates(paintFeatures(blankScreen(), FEATURE_COUNT));const BASELINE = { row: 32, left: 4, right: 29 };const BAR_WIDTH = 3;// Each comparison is a pair of bars; neither tool is the taller one every time.const COMPARISONS = [ { left: 6, ours: 13, theirs: 19 }, { left: 14, ours: 23, theirs: 14 }, { left: 22, ours: 17, theirs: 9 },];function paintBar(states: Uint8Array, left: number, height: number, state: number) { for (let up = 1; up <= height; up++) { for (let across = 0; across < BAR_WIDTH; across++) states[cellAt(left + across, BASELINE.row - up)] = state; }}/** The bars `grown` of the way to their full height, from 0 to 1. */export function paintBars(states: Uint8Array, grown: number) { states.fill(DOT_ABSENT); for (let column = BASELINE.left; column <= BASELINE.right; column++) states[cellAt(column, BASELINE.row)] = DOT_FILLED; for (const { left, ours, theirs } of COMPARISONS) { paintBar(states, left, Math.round(ours * grown), DOT_FILLED); paintBar(states, left + BAR_WIDTH + 1, Math.round(theirs * grown), DOT_HOLLOW); } return states;}export const BARS_FLAT = pictureOfStates(paintBars(blankScreen(), 0));export const BARS_GROWN = pictureOfStates(paintBars(blankScreen(), 1));export const COMPARE_CAPTIONS = { features: heroCaption('side by side', 'features', 'what each tool covers'), pricing: heroCaption('side by side', 'pricing', 'and where each one wins'),};/** The finished feature table: what the picture shows before it plays, and instead of playing with reduced motion. */export const COMPARE_RESTING = { ...FEATURES_CHECKED, texts: COMPARE_CAPTIONS.features };Reference: site src/components/sections/page-heroes/compare-hero-story.tsx.