VisualSite
Blog hero story
Dot animation of a post and announcement, with captions.
$ npx context-ds add story-blog-heroUsage
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.