VisualProduct
Pages cover
Dot animation showing several pages combining into one, with input and output labels.
$ npx context-ds add pages-coverUsage
Use it for
A feature card about scraping or parsing pages, inside a link, button or data-hover-morph card.
Avoid
- Changing the art's colors
- Labels longer than a word
Dependencies: glyph-engine, use-hover-dot-morph, theme-store
Example
examples/pages-cover.tsx
import { Card, CardDescription, CardTitle } from "@/components/ui/card";import { PagesCover } from "@/components/visuals/glyph/pages-cover";export function Product() { return ( <Card data-hover-morph className="mx-auto w-full max-w-sm"> <PagesCover className="w-full" /> <CardTitle>Scrape anything</CardTitle> <CardDescription>Any page in, clean JSON out.</CardDescription> </Card> );}Source
components/visuals/glyph/pages-cover.tsx
"use client";import { useCallback, useRef } from "react";import { PAGES_COVER_MASK } from "@/components/visuals/glyph/pages-cover-art";import { resolveColor, SURFACE_COLOR } from "@/components/visuals/glyph/surface-color";import { type LoadDotMorph, useHoverDotMorph } from "@/components/visuals/use-hover-dot-morph";import { useResolvedTheme } from "@/lib/theme";import { cn } from "@/lib/utils";// The cover is drawn on an 800 x 600 page but its pictures only use the middle: the three pages at rest and the one big// page they bind into both sit inside this box. Cropping to it lets the dots be drawn about twice as large.const COVER = { width: 800, height: 600 };const CROP = { left: 60, top: 80, width: 680, height: 440 };const percent = (value: number) => `${value * 100}%`;const clamp01 = (value: number) => Math.min(1, Math.max(0, value));const ART_BOX = { left: percent(-CROP.left / CROP.width), top: percent(-CROP.top / CROP.height), width: percent(COVER.width / CROP.width), height: percent(COVER.height / CROP.height),};// The big page the three bind into, in cover units: labels sit just off its sides once it has formed.const BIG_PAGE = { left: 245, right: 555 };const LABEL_GAP_PX = 8;const cropX = (x: number) => (x - CROP.left) / CROP.width;const cropY = (y: number) => (y - CROP.top) / CROP.height;export type PagesCoverLabel = { text: string; side: "left" | "right"; /** Height on the 800 x 600 cover. */ y: number };// What feeds the page on its left, and what it hands back on its right.const DEFAULT_LABELS: PagesCoverLabel[] = [ { text: "API", side: "left", y: 230 }, { text: "SCRAPE", side: "left", y: 370 }, { text: "JSON", side: "right", y: 300 },];// Labels arrive over the end of the bind, once the big page stands on its own, and leave as soon as it breaks up.const LABELS_FROM = 0.6;/** * The website's "pages" blog cover in the brand blue: three pages that gather and bind into one big page, as its blog * card does, while the nearest link, button or `data-hover-morph` is in view or hovered, with what goes in and what * comes out labeled either side of the big page. At rest, and with reduced motion, it is the art alone. */export function PagesCover({ labels = DEFAULT_LABELS, surfaceColor = SURFACE_COLOR, className,}: { labels?: PagesCoverLabel[]; surfaceColor?: string; className?: string;}) { const theme = useResolvedTheme(); const canvasRef = useRef<HTMLCanvasElement>(null); const labelsRef = useRef<HTMLSpanElement>(null); const load = useCallback<LoadDotMorph>( async (canvas, onPlayingChange) => { const { createPagesCoverMorph } = await import("@/components/visuals/glyph/cover-morph-engine"); // Written straight to the style every frame, so the labels follow the morph without re-rendering. const onFrame = ({ morph }: { morph: number }) => labelsRef.current?.style.setProperty("--labels-in", String(clamp01((morph - LABELS_FROM) / (1 - LABELS_FROM)))); return createPagesCoverMorph({ canvas, palette: { dots: getComputedStyle(canvas).color, background: resolveColor(canvas, surfaceColor) }, onPlayingChange, onFrame, }); }, // `theme` reloads the morph so it repaints in the new surface color. // eslint-disable-next-line react-hooks/exhaustive-deps [surfaceColor, theme], ); const isPlaying = useHoverDotMorph(canvasRef, load, { playOnView: true }); return ( <span aria-hidden="true" className={cn("relative block text-brand", className)} style={{ aspectRatio: `${CROP.width} / ${CROP.height}` }}> <span className="absolute inset-0 overflow-hidden"> <span className="absolute" style={ART_BOX}> <span className={cn("absolute inset-0 bg-current", isPlaying && "invisible")} style={{ mask: PAGES_COVER_MASK, WebkitMask: PAGES_COVER_MASK }} /> <canvas ref={canvasRef} className={cn("absolute inset-0 h-full w-full", !isPlaying && "invisible")} /> </span> </span> <span ref={labelsRef} className="absolute inset-0 [--labels-in:0]"> {labels.map(({ text, side, y }) => ( <span key={text} className="absolute font-data text-axis leading-none font-medium whitespace-nowrap" style={{ top: percent(cropY(y)), ...(side === "left" ? { right: percent(1 - cropX(BIG_PAGE.left)), marginRight: LABEL_GAP_PX } : { left: percent(cropX(BIG_PAGE.right)), marginLeft: LABEL_GAP_PX }), opacity: "var(--labels-in)", // Each drifts in toward the page as it appears. transform: `translate(calc((1 - var(--labels-in)) * ${side === "left" ? -6 : 6}px), -50%)`, }} > {text} </span> ))} </span> </span> );}Reference: context-dashboard c0ddf7a src/components/dots/pages-cover.tsx.