Blog hero storyDesign system
VisualSite

Blog hero story

Dot animation of a post and announcement, with captions.

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

Usage

Use it for

The blog page hero on the site. Plays only on screen; shows the written post with reduced motion.

Avoid

  • Stories as the main content of a page

Dependencies: story-parts, dot-engine

Example

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

Source

components/visuals/stories/blog-hero-story.tsx
'use client';import { BLOG_RESTING } from '@/components/visuals/stories/blog-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 loadBlogHeroStory: LoadDotStory = async (stage) => {	const { createBlogHeroStoryPlayer } = await import('@/components/visuals/stories/blog-hero-story-player');	return createBlogHeroStoryPlayer(stage);};/** * The blog hero's picture: a post writes itself line by line, then becomes a megaphone calling out the news. With * reduced motion it shows the written post. */export function BlogHeroStory({ className }: { className?: string }) {	return <DotStory grid={HERO_STORY_GRID} resting={BLOG_RESTING} load={loadBlogHeroStory} className={className} />;}
components/visuals/stories/blog-hero-story-player.ts
import { BLOG_CAPTIONS, LINE_COUNT, MEGAPHONE, POST_UNWRITTEN, POST_WRITTEN, paintPost } from '@/components/visuals/stories/blog-hero-story-scenes';import { HERO_STORY_GRID } 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 type { DotStoryPlayer, DotStoryStage } from '@/components/visuals/dot-engine/dot-story-player';/** * The blog, played as a loop: a post is written a line at a time, then melts into a megaphone whose sound waves * go out again and again. */const WRITING = { startsAt: 0.5, linesPerSecond: 3 };const WRITTEN_AT = WRITING.startsAt + LINE_COUNT / WRITING.linesPerSecond;const CALLING = { everySeconds: 0.45, holdSeconds: 3.4, settlesFor: 0.6 };const loudest = MEGAPHONE.length - 1;// The megaphone ends its hold with every wave out, so the post melts in from the picture that was last on screen.const wavesAt = (heldSeconds: number) => (heldSeconds > CALLING.holdSeconds - CALLING.settlesFor ? loudest : Math.floor(heldSeconds / CALLING.everySeconds) % MEGAPHONE.length);export function createBlogHeroStoryPlayer(stage: DotStoryStage): DotStoryPlayer {	return createBeatStoryPlayer(stage, {		grid: HERO_STORY_GRID,		resting: { beat: 0, heldSeconds: WRITTEN_AT + 0.8 },		beats: [			captionedBeat({				picture: POST_UNWRITTEN,				departure: POST_WRITTEN,				caption: BLOG_CAPTIONS.guides,				holdSeconds: WRITTEN_AT + 1.8,				isTyped: true,				animate: (states, heldSeconds) => void paintPost(states, Math.min(LINE_COUNT, Math.max(0, heldSeconds - WRITING.startsAt) * WRITING.linesPerSecond)),			}),			captionedBeat({				picture: MEGAPHONE[0],				departure: MEGAPHONE[loudest],				caption: BLOG_CAPTIONS.news,				holdSeconds: CALLING.holdSeconds,				isTyped: true,				animate: (states, heldSeconds) => states.set(MEGAPHONE[wavesAt(heldSeconds)].states),			}),		],	});}
components/visuals/stories/blog-hero-story-scenes.ts
import { HERO_STORY_GRID, between, blankScreen, 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 { pictureOf } from '@/components/visuals/dot-engine/dot-story-beats';import { paintShape, polygon, rectangle, roundedBox, solid, solidWithSpeckle, stack, type DotBox, type DotPainter } from '@/components/visuals/dot-engine/dot-story-cells';/** The blog hero's pictures, in the brand dot grid: a post being written, and a megaphone calling out the news. */const SHEET: DotBox = { left: 6, right: 26, top: 3, bottom: 34 };const TEXT_LEFT = 9;const HEADLINE = { rows: [7, 8], right: 20 };const LINES = [	{ row: 12, right: 23 },	{ row: 15, right: 21 },	{ row: 18, right: 23 },	{ row: 21, right: 17 },	{ row: 25, right: 22 },	{ row: 28, right: 23 },	{ row: 31, right: 15 },];export const LINE_COUNT = LINES.length;/** The post with `written` lines of its body set; the line being written is as long as its share. */export function paintPost(states: Uint8Array, written: number) {	return paintShape(HERO_STORY_GRID, states, roundedBox(SHEET, 2), (column, row) => {		if (HEADLINE.rows.includes(row)) return between(column, TEXT_LEFT, HEADLINE.right) ? DOT_FILLED : DOT_ABSENT;		const line = LINES.findIndex((candidate) => candidate.row === row);		if (line === -1 || line >= written) return DOT_ABSENT;		const right = TEXT_LEFT + (LINES[line].right - TEXT_LEFT) * Math.min(1, written - line);		return between(column, TEXT_LEFT, right) ? DOT_HOLLOW : DOT_ABSENT;	});}export const POST_UNWRITTEN = pictureOfStates(paintPost(blankScreen(), 0));export const POST_WRITTEN = pictureOfStates(paintPost(blankScreen(), LINE_COUNT));const HORN = polygon(	[		{ column: 7, row: 16 },		{ column: 21, row: 8 },		{ column: 21, row: 30 },		{ column: 7, row: 22 },	],	solidWithSpeckle);const HANDLE = rectangle({ left: 9, right: 12, top: 23, bottom: 29 }, solid);const CALL = { mouth: { column: 21, row: 19 }, radii: [4.5, 8.5], spread: 0.8, width: 0.55 };const soundWaves =	(count: number): DotPainter =>	(column, row) => {		const across = column - CALL.mouth.column;		const down = row - CALL.mouth.row;		if (across <= 0 || Math.abs(Math.atan2(down, across)) > CALL.spread) return DOT_ABSENT;		return CALL.radii.slice(0, count).some((radius) => Math.abs(Math.hypot(across, down) - radius) < CALL.width) ? DOT_FILLED : DOT_ABSENT;	};/** The megaphone with none, one and both of its sound waves out. */export const MEGAPHONE = [0, 1, 2].map((waves) => pictureOf(HERO_STORY_GRID, stack(HORN, HANDLE, soundWaves(waves))));export const BLOG_CAPTIONS = {	guides: heroCaption('from the team', 'guides', 'scraping, crawling, agents'),	news: heroCaption('from the team', 'news', 'launches and updates'),};/** The written post: what the picture shows before it plays, and instead of playing with reduced motion. */export const BLOG_RESTING = { ...POST_WRITTEN, texts: BLOG_CAPTIONS.guides };

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