Echo Reel
Rotating Text Headline for React
A giant word printed solid, with the words before and after it in the list stacked above and below as pale halftone proofs. Every few seconds the reel turns one row: the proof below rises into the middle while one roller pass moves the ink off the old word and onto the new one. Click a proof or press ↑ ↓ to turn it yourself, so it works as a headline that cycles who it's for or as a picker for plans.
<EchoReel />The demo follows as you drag.
One long recording in. Clips, captions and dubs for every channel out, cut while you sleep.
import { EchoReel } from "@/components/ui/echo-reel";
const AUDIENCES = ["Creators", "Podcasters", "Studios", "Brands"];
export function EchoReelDemo() {
return (
<section className="w-full max-w-4xl overflow-hidden rounded-3xl border border-[#0b0b0d]/10 bg-white px-5 pt-10 pb-12 text-[#0b0b0d] sm:px-10 sm:pt-12 dark:border-white/10 dark:bg-[#05060d] dark:text-[#f4f4f6]">
<h2 className="sr-only">Cutline is made for creators, podcasters, studios and brands</h2>
<p aria-hidden className="text-center text-sm font-semibold tracking-tight sm:text-base">
Cutline is made for
</p>
<EchoReel words={AUDIENCES} className="mt-2 text-[#2f3cff] dark:text-[#7b86ff]" />
<p className="mx-auto mt-5 max-w-[22rem] text-center text-[15px] leading-relaxed text-[#0b0b0d]/65 dark:text-[#f4f4f6]/65">
One long recording in. Clips, captions and dubs for every channel out, cut while you sleep.
</p>
<div className="mt-6 flex justify-center">
<a
href="#start"
className="rounded-full bg-[#2f3cff] px-5 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#1f2be6] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#2f3cff] dark:bg-[#5b68ff] dark:hover:bg-[#6d79ff] dark:focus-visible:outline-[#7b86ff]"
>
Start cutting free
</a>
</div>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Echo Reel: A giant word printed solid, with the words before and after it in the list stacked above and below as pale halftone proofs. Every few seconds the reel turns one row: the proof below rises into the middle while one roller pass moves the ink off the old word and onto the new one. Click a proof or press ↑ ↓ to turn it yourself, so it works as a headline that cycles who it's for or as a picker for plans. This demo, Audiences: A video tool's hero that cycles who it's made for. It holds while you point at it and stops once you pick a word. 1. Install it: npx shadcn@latest add @keyframeui/echo-reel-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/echo-reel-demo.json instead.) It adds the demo, components/echo-reel-demo.tsx, and the component, components/ui/echo-reel.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <EchoReel> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): How fast each turn plays, as a multiple of the default pace: the reel moving a row, the roller handing the ink over, the first print and the fades, together. `2` is twice as fast. 4. Don't change, in components/ui/echo-reel.tsx: - Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one way and the word inside it the other by the same amount, on one `rollTransition`, so the word never shifts. - Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view. - On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is handed over while the rows travel. `print` times only the first inking on view, before any turn. - Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's turn stops autoplay for good and is announced politely; autoplay turns are not. - Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only once that fade ends. The reduced flag reads false while hydrating, so the markup matches. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/echo-reel.md
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/echo-reel.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/echo-reelInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/echo-reel.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /** * Keep: * - Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one * way and the word inside it the other by the same amount, on one `rollTransition`, so the word * never shifts. * - Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps * its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view. * - On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is * handed over while the rows travel. `print` times only the first inking on view, before any turn. * - Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's * turn stops autoplay for good and is announced politely; autoplay turns are not. * - Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only * once that fade ends. The reduced flag reads false while hydrating, so the markup matches. */ const TIMING = { /** The reel moving one row, in ms, on its strong in-out curve. */ turn: { duration: 700, ease: "cubic-bezier(0.77, 0, 0.175, 1)" }, /** * The roller moving the ink across on a turn, in ms: its run, its lag behind the reel, its curve. * On a turn the roller rides the reel's own curve, so the ink crosses while the rows travel * instead of landing before they've moved. */ roll: { duration: 640, delay: 60, ease: "cubic-bezier(0.65, 0, 0.35, 1)" }, /** The first ink when the reel comes into view, in ms. */ print: { duration: 700, delay: 150, ease: "cubic-bezier(0.23, 1, 0.32, 1)" }, /** A word's proof going once the ink covers it, in ms: its fade, the wait first, its curve. */ proof: { duration: 300, hold: 450, ease: "ease-out" }, /** A row leaving or joining the visible stack, in ms, and its curve. */ fade: { duration: 400, ease: "ease-out" }, /** The ink fading in place under reduced motion, in ms, and its curve. */ reduced: { duration: 250, ease: "ease-out" }, /** Between turns by itself, in seconds (the `interval` prop). */ interval: 3, /** How fast each turn plays, as a multiple of the default pace (the `speed` prop). */ speed: 1, /** The slowest pace `speed` is held to, as a multiple of the default pace. */ minSpeed: 0.05, } as const; /* ───────────────────────────────────────────────────────── * ECHO REEL STORYBOARD * * rest one giant word printed solid in the ink. The words * before and after it in the list sit above and below * as pale halftone proofs, each row overlapping the * next, like the impressions round a print reel * view the first time it shows, the middle word is still a * proof. 150ms later a roller inks it, left to right, * in 0.7s * turn every `interval` seconds the reel turns one row in * 0.7s (strong in-out). One roller pass, 60ms behind and * on the same in-out curve, moves the ink across: it * comes off the old word left to right as that word * rises into the top proof, and goes onto the word rising * into the middle at the same edge, so the ink is handed * over in transit. The row leaving the top fades as it * goes; the next word fades in below * back a turn the other way runs in reverse: the reel drops * and the roller travels right to left * pick click a proof, or focus it and press ↑ ↓, and the reel * turns to that word. A visitor's turn stops the * autoplay for good and is announced politely; autoplay * turns are not announced * hover a proof under the pointer darkens a step, so it reads * as something to press. Hover or focus on the reel * holds the autoplay * sleep autoplay waits while the reel is off screen or the tab * is hidden * reduced nothing travels and nothing wipes: the rows swap at * once and the new word's ink fades in over its proof * in 0.25s * ───────────────────────────────────────────────────────── */ const REEL = { step: 0.68, // em between one row and the next; rows are 1.32em tall, so they overlap pad: { x: 0.08, y: 0.16 }, // em around each word, so a wipe never clips a glyph fit: 0.94, // share of the width the longest word fills far: 0.5, // opacity of the outer proofs with echoes={2} measure: 100, // px the words are measured at } as const; const ROW = 1 + REEL.pad.y * 2; // em, one row's height const STEP_PCT = (REEL.step / ROW) * 100; // a row's step as a share of its own height const CAP_TOP = 0.29; // em from a row's top to its capitals const BASELINE = 1.01; // em from a row's top to its baseline const mod = (a: number, n: number) => ((a % n) + n) % n; /** The signed shortest way round a reel of `n` from index `a` to `b`; a tie goes forward. */ function shortest(a: number, b: number, n: number) { const d = mod(b - a, n); return d > n / 2 ? d - n : d; } const REDUCED_QUERY = "(prefers-reduced-motion: reduce)"; function subscribeReduced(onChange: () => void) { const query = window.matchMedia(REDUCED_QUERY); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); } /** Live, and false on the server and in the hydration render, so the markup matches. */ function usePrefersReducedMotion() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED_QUERY).matches, () => false, ); } export interface EchoReelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> { /** The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself. */ words: string[]; /** The index of the word in the middle, when the parent owns it. Pair it with `onValueChange`. */ value?: number; /** The index the reel starts on when it runs by itself. */ defaultValue?: number; /** Called with the new index each time the reel turns, and whether autoplay or the visitor turned it. */ onValueChange?: (index: number, reason: "auto" | "user") => void; /** Seconds between turns by itself, or `false` to wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it. */ interval?: number | false; /** How many proofs sit on each side of the word. */ echoes?: 1 | 2; /** How fast each turn plays, as a multiple of the default pace: the reel, the roller, the first print and the fades together. `2` is twice as fast. */ speed?: number; } /* ── Row ──────────────────────────────────────────────── */ // One word on the reel: its halftone proof, and the ink over it behind a // window that slides across to roll the ink on or off. type RowProps = { word: string; offset: number; echoes: number; inked: boolean; first: boolean; hot: boolean; reduced: boolean; speed: number; }; const Row = React.memo(function Row({ word, offset, echoes, inked, first, hot, reduced, speed }: RowProps) { const away = Math.abs(offset); const solid = offset === 0 && inked; // Where the ink's window sits: over the word, waiting to its left (rows still // to come, below), or gone off to its right (rows already printed, above). const gate = solid ? 0 : offset < 0 ? 100 : -100; const roll = solid && first ? TIMING.print : TIMING.roll; // Every move on a turn, scaled together by `speed`. const ms = (t: number) => t / speed; const rollTransition = reduced ? "none" : `transform ${ms(roll.duration)}ms ${roll.ease} ${ms(roll.delay)}ms`; const pad = `${REEL.pad.y}em ${REEL.pad.x}em`; return ( <div className="absolute inset-x-0 flex justify-center" style={{ top: `${echoes * REEL.step}em`, height: `${ROW}em`, zIndex: 10 - away, transform: `translateY(${offset * STEP_PCT}%)`, opacity: away > echoes ? 0 : away === 2 ? REEL.far : 1, visibility: away > echoes + 1 ? "hidden" : undefined, willChange: away <= 1 ? "transform" : undefined, transition: reduced ? "none" : `transform ${ms(TIMING.turn.duration)}ms ${TIMING.turn.ease}, opacity ${ms(TIMING.fade.duration)}ms ${TIMING.fade.ease}`, ...(hot ? ({ "--echo-reel-tint": "color-mix(in oklab, var(--echo-reel-ink) 62%, transparent)" } as React.CSSProperties) : null), }} > <span className="relative block whitespace-nowrap" style={{ padding: pad }}> <span className="invisible">{word}</span> {/* The proof: a pale fill and a hairline, with a halftone over it. */} <span className="absolute inset-0" style={{ opacity: solid ? 0 : 1, transition: reduced ? solid ? `opacity 0s linear ${TIMING.reduced.duration}ms` : "none" : `opacity ${ms(TIMING.proof.duration)}ms ${TIMING.proof.ease} ${solid ? ms(TIMING.proof.hold) : 0}ms`, }} > <span className="absolute inset-0" style={{ padding: pad, WebkitTextFillColor: "transparent", // not `color`, which would turn the tint (from currentColor) transparent too WebkitTextStroke: "max(1px, 0.005em) var(--echo-reel-tint)", backgroundImage: "linear-gradient(color-mix(in oklab, var(--echo-reel-tint) 26%, transparent) 0 0)", WebkitBackgroundClip: "text", backgroundClip: "text", }} > {word} </span> <span className="absolute inset-0" style={{ padding: pad, WebkitTextFillColor: "transparent", // not `color`, which would turn the tint (from currentColor) transparent too // Two offset grids of dots make the 45° screen of a halftone. backgroundImage: "radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%), radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%)", backgroundSize: "var(--echo-reel-dot) var(--echo-reel-dot)", backgroundPosition: "0 0, calc(var(--echo-reel-dot) / 2) calc(var(--echo-reel-dot) / 2)", WebkitBackgroundClip: "text", backgroundClip: "text", maskImage: "radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)", WebkitMaskImage: "radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)", }} > {word} </span> </span> {/* The ink, behind a window that slides across while the word stays put. */} <span className="absolute inset-0 overflow-hidden" style={{ transform: reduced ? undefined : `translateX(${gate}%)`, opacity: reduced ? (solid ? 1 : 0) : undefined, willChange: away <= 1 && !reduced ? "transform" : undefined, transition: reduced ? solid ? `opacity ${TIMING.reduced.duration}ms ${TIMING.reduced.ease}` : "none" : rollTransition, }} > <span className="block size-full" style={{ padding: pad, color: "var(--echo-reel-ink)", transform: reduced ? undefined : `translateX(${-gate}%)`, willChange: away <= 1 && !reduced ? "transform" : undefined, transition: rollTransition, }} > {word} </span> </span> </span> </div> ); }); /* ── EchoReel ─────────────────────────────────────────── */ export function EchoReel({ words, value, defaultValue = 0, onValueChange, interval = TIMING.interval, echoes = 1, speed = TIMING.speed, className, style, onPointerEnter, onPointerLeave, onFocus, onBlur, ...props }: EchoReelProps) { const n = words.length; const spins = n > 1; const reduced = usePrefersReducedMotion(); const rootRef = React.useRef<HTMLDivElement>(null); const reelRef = React.useRef<HTMLDivElement>(null); const measureRef = React.useRef<HTMLSpanElement>(null); // The reel's absolute position: slot k shows words[k mod n]. It only ever // moves the short way round, so each row keeps its element through a wrap. const [slot, setSlot] = React.useState(() => value ?? defaultValue); const [seen, setSeen] = React.useState(value); if (spins && value !== undefined && value !== seen) { setSeen(value); setSlot(slot + shortest(mod(slot, n), value, n)); } const index = n ? mod(slot, n) : 0; const [printed, setPrinted] = React.useState(false); const [turned, setTurned] = React.useState(false); const [inView, setInView] = React.useState(false); const [visible, setVisible] = React.useState(true); const [hovered, setHovered] = React.useState(false); const [focused, setFocused] = React.useState(false); const [stopped, setStopped] = React.useState(false); const [hot, setHot] = React.useState<number | null>(null); const [announce, setAnnounce] = React.useState(""); const inked = printed || reduced; const turn = (delta: number, reason: "auto" | "user") => { if (!spins || delta === 0) return; const next = mod(slot + delta, n); setSlot(slot + delta); setTurned(true); if (value !== undefined) setSeen(next); if (reason === "user") { setStopped(true); setAnnounce(words[next]); } onValueChange?.(next, reason); }; // The timer calls the latest `turn` without restarting on every render. const turnRef = React.useRef(turn); React.useLayoutEffect(() => { turnRef.current = turn; }); const every = interval === false ? 0 : interval; const playing = spins && every > 0 && inked && inView && visible && !stopped && !hovered && !focused; React.useEffect(() => { if (!playing) return; const id = window.setTimeout(() => turnRef.current(1, "auto"), every * 1000); return () => window.clearTimeout(id); }, [playing, every, slot]); // Print on first view; autoplay sleeps off screen. React.useEffect(() => { const root = rootRef.current; if (!root) return; const io = new IntersectionObserver( ([entry]) => { setInView(entry.isIntersecting); if (entry.isIntersecting) setPrinted(true); }, { threshold: 0.25 }, ); io.observe(root); return () => io.disconnect(); }, []); React.useEffect(() => { const sync = () => setVisible(!document.hidden); document.addEventListener("visibilitychange", sync); return () => document.removeEventListener("visibilitychange", sync); }, []); // Fit the longest word to the width once the font is in, and on every resize. const key = words.join("\n"); React.useLayoutEffect(() => { const root = rootRef.current; const reel = reelRef.current; const measure = measureRef.current; if (!root || !reel || !measure) return; let alive = true; const fit = () => { if (!alive) return; let widest = 0; for (const child of Array.from(measure.children)) { widest = Math.max(widest, child.getBoundingClientRect().width); } const width = root.clientWidth; if (!widest || !width) return; reel.style.fontSize = `${((width * REEL.fit) / widest) * REEL.measure}px`; }; fit(); const ro = new ResizeObserver(fit); ro.observe(root); document.fonts?.ready.then(fit); return () => { alive = false; ro.disconnect(); }; }, [key]); if (!n) return null; const longest = Math.max(...words.map((word) => word.length), 1); // Before measuring (and on the server): roughly 0.56em a letter at this weight. const fallback = `${((REEL.fit * 100) / (longest * 0.56)).toFixed(2)}cqi`; // Enough rows either side that a jump the short way round never mounts a // row where it can be seen. const reach = spins ? echoes + 1 + Math.floor(n / 2) : echoes; const rows: number[] = []; for (let k = slot - reach; k <= slot + reach; k++) rows.push(k); const sides: number[] = []; if (spins) for (let o = -echoes; o <= echoes; o++) if (o !== 0) sides.push(o); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); turn(1, "user"); } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); turn(-1, "user"); } }; return ( <div ref={rootRef} className={cn( "@container/echo-reel relative w-full select-none [--echo-reel-ink:currentColor] [--echo-reel-tint:color-mix(in_oklab,var(--echo-reel-ink)_38%,transparent)] [--echo-reel-dot:clamp(4px,0.028em,8px)]", className, )} style={style} onPointerEnter={(e) => { if (e.pointerType === "mouse") setHovered(true); onPointerEnter?.(e); }} onPointerLeave={(e) => { setHovered(false); setHot(null); onPointerLeave?.(e); }} onFocus={(e) => { setFocused(true); onFocus?.(e); }} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); onBlur?.(e); }} {...props} > <span className="sr-only">{words[index]}</span> <span className="sr-only" aria-live="polite"> {announce} </span> <div ref={reelRef} className="relative font-black leading-none tracking-[-0.06em] [overflow:clip]" style={{ fontSize: fallback, height: `${ROW + echoes * 2 * REEL.step}em` }} > <div aria-hidden className="absolute inset-0"> {rows.map((k) => ( <Row key={k} word={words[mod(k, n)]} offset={k - slot} echoes={echoes} inked={inked} first={!turned} hot={hot === k - slot} reduced={reduced} speed={Math.max(TIMING.minSpeed, speed)} /> ))} </div> {/* A button over each proof's visible band. Kept by side, not by word, so focus stays put while the reel turns under it. */} {sides.map((o) => ( <button key={o} type="button" aria-label={`Show ${words[mod(slot + o, n)]}`} className="absolute inset-x-0 z-20 cursor-pointer rounded-[0.12em] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--echo-reel-ink)]" style={{ top: `${(echoes + (o < 0 ? o : o - 1)) * REEL.step + (o < 0 ? CAP_TOP : BASELINE)}em`, height: `${REEL.step}em`, }} onPointerEnter={(e) => { if (e.pointerType === "mouse") setHot(o); }} onPointerLeave={() => setHot(null)} onFocus={() => setHot(o)} onBlur={() => setHot(null)} onClick={() => turn(o, "user")} onKeyDown={onKeyDown} /> ))} </div> {/* Every word at a fixed size, for fitting the longest to the width. */} <span ref={measureRef} aria-hidden className="pointer-events-none invisible absolute top-0 left-0 font-black leading-none tracking-[-0.06em] whitespace-nowrap" style={{ fontSize: REEL.measure }} > {words.map((word, i) => ( <span key={i} className="absolute top-0 left-0"> {word} </span> ))} </span> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Echo Reel. Each one installs whole, with the component included.
Plan Picker
The giant word picks a pricing plan: click the proof above or below, or use the arrow keys, and the price card beside it changes.
Click a plan above or below, or use ↑ ↓
For an in-house team keeping five brands in line.
- Five brand kits, five seats
- Shared templates and review links
- Version history on every asset
"use client";
import * as React from "react";
import { EchoReel } from "@/components/ui/echo-reel";
const PLANS = [
{
name: "Solo",
price: 12,
blurb: "For one brand you're building yourself.",
lines: [
"One brand kit",
"Logo, palette and type pairings",
"Export to Figma and PDF",
],
cta: "Start with Solo",
},
{
name: "Team",
price: 36,
blurb: "For an in-house team keeping five brands in line.",
lines: [
"Five brand kits, five seats",
"Shared templates and review links",
"Version history on every asset",
],
cta: "Try Team free for 14 days",
},
{
name: "Studio",
price: 96,
blurb: "For agencies handing kits to clients.",
lines: [
"Unlimited client kits, 20 seats",
"White-label handoff pages",
"Priority support and SSO",
],
cta: "Talk to us about Studio",
},
];
function Check() {
return (
<svg
viewBox="0 0 16 16"
aria-hidden
className="mt-0.5 size-4 shrink-0 text-[#ff3d1f] dark:text-[#ff6a4d]"
>
<path
d="M3.5 8.5l3 3 6-7"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function EchoReelPlansDemo() {
const [plan, setPlan] = React.useState(1);
return (
<section className="@container w-full max-w-4xl overflow-hidden rounded-3xl border border-[#0b0b0d]/10 bg-white px-5 pt-8 pb-8 text-[#0b0b0d] @[44rem]:px-10 @[44rem]:pb-10 dark:border-white/10 dark:bg-[#08080b] dark:text-[#f4f4f6]">
<div className="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
<h2 className="text-lg font-semibold tracking-tight">Tinta pricing</h2>
<p className="text-[13px] text-[#0b0b0d]/55 dark:text-[#f4f4f6]/55">
Click a plan above or below, or use <kbd className="font-sans">↑</kbd>{" "}
<kbd className="font-sans">↓</kbd>
</p>
</div>
<div className="mt-6 grid items-center gap-6 @[44rem]:mt-8 @[44rem]:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] @[44rem]:gap-10">
<EchoReel
words={PLANS.map((p) => p.name)}
value={plan}
onValueChange={(index) => setPlan(index)}
interval={false}
aria-label="Plan"
className="mx-auto max-w-md text-[#ff3d1f] dark:text-[#ff5a3a]"
/>
{/* Every plan in one grid cell, so the card keeps its height while they cross. */}
<div className="mx-auto grid w-full max-w-sm">
{PLANS.map((p, i) => {
const on = i === plan;
return (
<div
key={p.name}
aria-hidden={!on}
inert={!on}
className="[grid-area:1/1] rounded-2xl border border-[#0b0b0d]/10 bg-white p-5 shadow-sm transition-[opacity,transform] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity dark:border-white/10 dark:bg-white/[0.04]"
style={{
opacity: on ? 1 : 0,
transform: on ? "none" : `translateY(${i < plan ? -6 : 6}px)`,
transitionDelay: on ? "120ms" : "0ms",
}}
>
<div className="flex items-baseline gap-1.5">
<span className="text-4xl font-semibold tracking-tight tabular-nums">
${p.price}
</span>
<span className="text-sm text-[#0b0b0d]/55 dark:text-[#f4f4f6]/55">
a month
</span>
</div>
<p className="mt-1 text-sm text-[#0b0b0d]/65 dark:text-[#f4f4f6]/65">
{p.blurb}
</p>
<ul className="mt-4 space-y-2 text-sm">
{p.lines.map((line) => (
<li key={line} className="flex gap-2">
<Check />
{line}
</li>
))}
</ul>
<a
href="#plan"
tabIndex={on ? undefined : -1}
className="mt-5 flex h-10 items-center justify-center rounded-full bg-[#ff3d1f] text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e8331a] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ff3d1f] dark:bg-[#ff5a3a] dark:text-[#0b0b0d] dark:hover:bg-[#ff6e52]"
>
{p.cta}
</a>
</div>
);
})}
</div>
</div>
</section>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content. Echo Reel: A giant word printed solid, with the words before and after it in the list stacked above and below as pale halftone proofs. Every few seconds the reel turns one row: the proof below rises into the middle while one roller pass moves the ink off the old word and onto the new one. Click a proof or press ↑ ↓ to turn it yourself, so it works as a headline that cycles who it's for or as a picker for plans. This demo, Plan Picker: The giant word picks a pricing plan: click the proof above or below, or use the arrow keys, and the price card beside it changes. 1. Install it: npx shadcn@latest add @keyframeui/echo-reel-plans-demo (If the CLI can't find @keyframeui, use https://keyframeui.com/r/echo-reel-plans-demo.json instead.) It adds the demo, components/echo-reel-plans-demo.tsx, and the component, components/ui/echo-reel.tsx. 2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <EchoReel> on its own. 3. These props set its feel. Change them before you change the file: - speed (default 1): How fast each turn plays, as a multiple of the default pace: the reel moving a row, the roller handing the ink over, the first print and the fades, together. `2` is twice as fast. 4. Don't change, in components/ui/echo-reel.tsx: - Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one way and the word inside it the other by the same amount, on one `rollTransition`, so the word never shifts. - Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view. - On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is handed over while the rows travel. `print` times only the first inking on view, before any turn. - Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's turn stops autoplay for good and is announced politely; autoplay turns are not. - Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only once that fade ends. The reduced flag reads false while hydrating, so the markup matches. Its timing is all in the TIMING block at the top of that file. Docs: https://keyframeui.com/components/echo-reel.md
Props
| Prop | Type | Default | Description |
|---|---|---|---|
words Required | string[] | None | The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself. |
value | number | None | The index of the word in the middle, when the parent owns it. Pair it with onValueChange. |
defaultValue | number | 0 | The index the reel starts on when it runs by itself. |
onValueChange | (index: number, reason: "auto" | "user") => void | None | Called with the new index each time the reel turns, and whether autoplay or the visitor turned it. |
interval | number | false | 3 | Seconds between turns by itself, or false to wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it. |
speed | number | 1 | How fast each turn plays, as a multiple of the default pace: the reel moving a row, the roller handing the ink over, the first print and the fades, together. 2 is twice as fast. |
echoes | 1 | 2 | 1 | How many proofs sit on each side of the word. |
className | string | None | Extra classes for the reel. Its text color is the ink; set --echo-reel-tint for the proofs (default the ink at 38%) and --echo-reel-dot for the halftone pitch (default clamp(4px, 0.028em, 8px)). The longest word is sized to fill the width, so size the reel with its container. |
wordsRequiredType
string[]The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself.
valueType
numberThe index of the word in the middle, when the parent owns it. Pair it with
onValueChange.defaultValueType
numberDefault0The index the reel starts on when it runs by itself.
onValueChangeType
(index: number, reason: "auto" | "user") => voidCalled with the new index each time the reel turns, and whether autoplay or the visitor turned it.
intervalType
number | falseDefault3Seconds between turns by itself, or
falseto wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it.speedType
numberDefault1How fast each turn plays, as a multiple of the default pace: the reel moving a row, the roller handing the ink over, the first print and the fades, together.
2is twice as fast.echoesType
1 | 2Default1How many proofs sit on each side of the word.
classNameType
stringExtra classes for the reel. Its text color is the ink; set
--echo-reel-tintfor the proofs (default the ink at 38%) and--echo-reel-dotfor the halftone pitch (defaultclamp(4px, 0.028em, 8px)). The longest word is sized to fill the width, so size the reel with its container.