Grants hero storyDesign system
VisualSite

Grants hero story

Dot animation of experiment progress and grant credits, with captions.

npx context-ds add story-grants-hero
On the site

Usage

Use it for

The research grants page hero on the site. Plays only on screen; shows the full award with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: story-parts, dot-engine

Example

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

Source

components/visuals/stories/grants-hero-story.tsx
'use client';import { GRANTS_RESTING } from '@/components/visuals/stories/grants-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 loadGrantsHeroStory: LoadDotStory = async (stage) => {	const { createGrantsHeroStoryPlayer } = await import('@/components/visuals/stories/grants-hero-story-player');	return createGrantsHeroStoryPlayer(stage);};/** * The research grants hero's picture: a reward curve climbing as an experiment runs, then the grant's usage credits * awarded a tranche at a time. With reduced motion it shows the full award. */export function GrantsHeroStory({ className }: { className?: string }) {	return <DotStory grid={HERO_STORY_GRID} resting={GRANTS_RESTING} load={loadGrantsHeroStory} className={className} />;}
components/visuals/stories/grants-hero-story-player.ts
import { CREDITS_AWARDED, CREDITS_UNAWARDED, CREDIT_CAPTIONS, CURVE_CAPTION, CURVE_DRAWN, CURVE_END, CURVE_START, CURVE_UNDRAWN, TRANCHE_COUNT, paintCredits, paintCurve } from '@/components/visuals/stories/grants-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 } from '@/components/visuals/dot-engine/dot-story-cells';import type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * One grant, played as a loop: an experiment's reward curve is drawn across its chart, then melts into the award's * usage credits, stacked up a tranche at a time as the total climbs. */const DRAWING = { startsAt: 0.4, seconds: 2.2 };const AWARDING = { startsAt: 0.5, everySeconds: 1.2, textSeconds: 0.25 };const AWARDED_AT = AWARDING.startsAt + (TRANCHE_COUNT - 1) * AWARDING.everySeconds;const awardedAt = (heldSeconds: number) => stepAt(heldSeconds, AWARDING, TRANCHE_COUNT - 1);export function createGrantsHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: HERO_STORY_GRID,		resting: { beat: 1, heldSeconds: AWARDED_AT + 0.8 },		beats: [			captionedBeat({				picture: CURVE_UNDRAWN,				departure: CURVE_DRAWN,				caption: CURVE_CAPTION,				holdSeconds: DRAWING.startsAt + DRAWING.seconds + 1.4,				isTyped: true,				animate: (states, heldSeconds) => void paintCurve(states, CURVE_START + Math.round((CURVE_END - CURVE_START) * clamp01((heldSeconds - DRAWING.startsAt) / DRAWING.seconds))),			}),			{				...captionedBeat({ picture: CREDITS_UNAWARDED, departure: CREDITS_AWARDED, caption: CREDIT_CAPTIONS[0], holdSeconds: AWARDED_AT + 2.2, animate: (states, heldSeconds) => void paintCredits(states, awardedAt(heldSeconds) + 1) }),				write(pen, heldSeconds) {					const awarded = awardedAt(heldSeconds);					if (awarded < 0) return;					const written = clamp01((heldSeconds - AWARDING.startsAt - awarded * AWARDING.everySeconds) / AWARDING.textSeconds);					CREDIT_CAPTIONS[awarded].forEach((text, line) => pen.text(text, line === 0 ? 1 : written));				},			},		],	});}
components/visuals/stories/grants-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, solidWithSpeckle, type DotBox } from '@/components/visuals/dot-engine/dot-story-cells';/** * The research grants hero's pictures, in the brand dot grid: an experiment's reward curve climbing over its * episodes, and the grant's usage credits as a stack awarded a tranche at a time. */const AXES = { left: 5, right: 29, top: 6, bottom: 31 };const CURVE = { firstColumn: AXES.left + 2, climb: 21, settlesOver: 7, thickness: 2 };const EPISODE_EVERY = 3;/** The last column the curve reaches. */export const CURVE_END = AXES.right;const rewardRow = (column: number) => AXES.bottom - 3 - Math.round(CURVE.climb * (1 - Math.exp(-(column - CURVE.firstColumn) / CURVE.settlesOver)));// Single episodes land a little above or below the curve they average out to.const episodeScatter = (column: number) => ((column * 7) % 5) - 2;/** The chart with its curve drawn as far as column `reached`, and the episodes behind it as rings. */export function paintCurve(states: Uint8Array, reached: number) {	states.fill(DOT_ABSENT);	for (let row = AXES.top; row <= AXES.bottom; row++) states[cellAt(AXES.left, row)] = DOT_FILLED;	for (let column = AXES.left; column <= AXES.right; column++) states[cellAt(column, AXES.bottom)] = DOT_FILLED;	for (let column = CURVE.firstColumn; column <= Math.min(reached, CURVE_END); column++) {		const scatter = episodeScatter(column);		if (column % EPISODE_EVERY === 0 && scatter !== 0) states[cellAt(column, rewardRow(column) + scatter * 2)] = DOT_HOLLOW;		for (let down = 0; down < CURVE.thickness; down++) states[cellAt(column, rewardRow(column) + down)] = DOT_FILLED;	}	return states;}export const CURVE_START = CURVE.firstColumn - 1;export const CURVE_UNDRAWN = pictureOfStates(paintCurve(blankScreen(), CURVE_START));export const CURVE_DRAWN = pictureOfStates(paintCurve(blankScreen(), CURVE_END));const STACK = { left: 8, right: 24 };/** Each tranche of an award, the total it brings the credits to, and when it is added. */const TRANCHES = [	{ top: 26, bottom: 32, total: '$5,000', when: 'on acceptance' },	{ top: 18, bottom: 24, total: '$10,000', when: 'after a progress review' },	{ top: 4, bottom: 16, total: '$20,000', when: 'after a research milestone' },];export const TRANCHE_COUNT = TRANCHES.length;const trancheBox = ({ top, bottom }: (typeof TRANCHES)[number]): DotBox => ({ ...STACK, top, bottom });/** The stack with its first `awarded` tranches solid, and those still to come as rings. */export function paintCredits(states: Uint8Array, awarded: number) {	TRANCHES.forEach((tranche, index) => paintShape(HERO_STORY_GRID, states, roundedBox(trancheBox(tranche), 2), index < awarded ? solidWithSpeckle : rings));	return states;}export const CREDITS_UNAWARDED = pictureOfStates(paintCredits(blankScreen(), 0));export const CREDITS_AWARDED = pictureOfStates(paintCredits(blankScreen(), TRANCHE_COUNT));export const CURVE_CAPTION = heroCaption('RL experiments', 'web data', 'search, scrape, company data');export const CREDIT_CAPTIONS = TRANCHES.map(({ total, when }) => heroCaption('usage credits', total, when));/** The full award: what the picture shows before it plays, and instead of playing with reduced motion. */export const GRANTS_RESTING = { ...CREDITS_AWARDED, texts: CREDIT_CAPTIONS[TRANCHE_COUNT - 1] };

Reference: site src/components/sections/page-heroes/grants-hero-story.tsx.