Playhead Reel
A whole archive in one thin strip, with a playhead that walks along it as you scroll. Whatever's under the playhead opens into a loose collage, and the ruler below is a scrubber you can drag.
Selected work · 01 / 06
Halden House
Architecture · 2025
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { PlayheadReel, type PlayheadReelGroup } from "@/components/ui/playhead-reel";
const photo = (id: string, aspect: number) =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${Math.round(560 * aspect)}&h=560&q=65`;
const shot = (id: string, aspect: number, alt: string) => ({ src: photo(id, aspect), aspect, alt });
type Project = PlayheadReelGroup & { name: string; discipline: string; year: number };
const projects: Project[] = [
{
label: "Halden",
name: "Halden House",
discipline: "Architecture",
year: 2025,
items: [
shot("1600585154340-be6161a56a0c", 4 / 3, "Halden House at dusk"),
shot("1600566753190-17f0baa2a6c3", 4 / 3, "Street facade in timber and render"),
shot("1600585154526-990dced4db0d", 3 / 4, "Slatted screen over the stair"),
shot("1600607687939-ce8a6c25118c", 3 / 2, "Open living room"),
shot("1600607687644-c7171b42498f", 4 / 3, "Main bedroom"),
shot("1600573472550-8090b5e0745e", 3 / 4, "Stair down to the pool"),
],
},
{
label: "Maison Vert",
name: "Maison Vert",
discipline: "Interiors",
year: 2024,
items: [
shot("1618221195710-dd6b41faaea6", 3 / 2, "Long living room with clerestory"),
shot("1586023492125-27b2c045efd7", 3 / 4, "Yellow lounge chair"),
shot("1618219908412-a29a1bb7b86e", 4 / 3, "Hallway with round mirror"),
shot("1615529182904-14819c35db37", 4 / 3, "Snug with rattan pendants"),
shot("1616486338812-3dadae4b4ace", 3 / 2, "Sitting room"),
shot("1524758631624-e2822e304c36", 4 / 3, "Studio lounge"),
],
},
{
label: "Objet",
name: "Objet",
discipline: "Product photography",
year: 2024,
items: [
shot("1503602642458-232111445657", 3 / 4, "Birch stool"),
shot("1592078615290-033ee584e267", 3 / 4, "Black shell chair"),
shot("1567538096630-e0c55bd6374c", 1, "Tufted armchair"),
shot("1555041469-a586c61ea9bc", 3 / 2, "Green velvet sofa"),
shot("1544816155-12df9643f363", 3 / 4, "Canvas tote"),
shot("1523275335684-37898b6baf30", 4 / 3, "White watch"),
shot("1505740420928-5e560c06d30e", 1, "Headphones on yellow"),
],
},
{
label: "Lumen",
name: "Lumen",
discipline: "Motion identity",
year: 2023,
items: [
shot("1618005182384-a83a8bd57fbe", 3 / 2, "Violet wave"),
shot("1620641788421-7a1c342ea42e", 4 / 3, "Glow on indigo"),
shot("1617791160505-6f00504e3519", 3 / 4, "Folded ribbon"),
shot("1634017839464-5c339ebe3cb4", 4 / 3, "Blue lines"),
shot("1557672172-298e090bd0f1", 1, "Liquid marble"),
shot("1579546929518-9e396f3cc809", 3 / 2, "Soft gradient"),
shot("1604076913837-52ab5629fba9", 3 / 4, "Light leak"),
],
},
{
label: "Corte",
name: "Corte",
discipline: "Restaurant identity",
year: 2023,
items: [
shot("1517248135467-4c7edcad34c4", 3 / 2, "Dining room"),
shot("1414235077428-338989a2e8c0", 4 / 3, "Dinner service"),
shot("1540189549336-e6e99c3679fe", 1, "Salad from above"),
shot("1504674900247-0877df9cc836", 4 / 3, "Plates for the table"),
shot("1511920170033-f8396924c348", 3 / 4, "Espresso"),
shot("1565299624946-b28f40a0ae38", 1, "Pizza from the oven"),
],
},
{
label: "Parcel",
name: "Parcel",
discipline: "Product design",
year: 2022,
items: [
shot("1460925895917-afdab827c52f", 3 / 2, "Analytics dashboard"),
shot("1609921212029-bb5a28e60960", 4 / 3, "App screens on a desktop"),
shot("1558655146-d09347e92766", 4 / 3, "Component library"),
shot("1586953208448-b95a79798f07", 3 / 4, "Mobile app"),
shot("1586717791821-3f44a563fa4c", 4 / 3, "Sketching flows"),
shot("1572044162444-ad60f128bdea", 3 / 2, "Choosing colors"),
],
},
];
const pad = (n: number) => String(n).padStart(2, "0");
const slug = (name: string) => name.toLowerCase().replace(/\s+/g, "-");
export function PlayheadReelDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [view, setView] = React.useState({ group: 0, direction: 1 });
const project = projects[view.group];
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
<div ref={scrollRef} className="relative h-[var(--demo-height,38rem)] overflow-y-auto overscroll-contain">
<PlayheadReel
groups={projects}
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,38rem)]"
onActiveChange={({ group }) =>
setView((prev) =>
prev.group === group ? prev : { group, direction: group > prev.group ? 1 : -1 },
)
}
header={
<header className="flex items-end justify-between gap-6 px-4 pt-5 pb-4 sm:px-8 sm:pt-7">
<div className="min-w-0 flex-1">
<p className="font-mono text-[11px] tracking-widest text-zinc-500 uppercase tabular-nums">
Selected work · {pad(view.group + 1)} / {pad(projects.length)}
</p>
<Roll id={view.group} direction={view.direction} className="mt-2">
<h2 className="truncate text-3xl leading-[1.15] font-semibold tracking-tight sm:text-5xl">
{project.name}
</h2>
</Roll>
</div>
<div className="shrink-0 text-right">
<Roll id={view.group} direction={view.direction}>
<p className="text-sm leading-6 text-zinc-500 tabular-nums">
{project.discipline} · {project.year}
</p>
</Roll>
<a
href={`#${slug(project.name)}`}
className="group/link mt-1 inline-flex items-center gap-1.5 text-sm font-medium outline-current focus-visible:outline-2 focus-visible:outline-offset-2"
>
View case study
<span
aria-hidden
className="transition-transform duration-200 ease-out group-hover/link:translate-x-0.5"
>
→
</span>
</a>
</div>
</header>
}
/>
</div>
</div>
);
}
/** Rolls its content out and the next one in, up when moving forward and down when going back. */
function Roll({
id,
direction,
className,
children,
}: {
id: number;
direction: number;
className?: string;
children: React.ReactNode;
}) {
const reduceMotion = useReducedMotion();
return (
<div className={cn("relative overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={id}
custom={direction}
variants={{
enter: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * 100}%` }),
center: { y: 0, opacity: 1 },
exit: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * -100}%` }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduceMotion ? 0.2 : 0.5, ease: [0.23, 1, 0.32, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/playhead-reel.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/playhead-reelInstall the dependencies
npm i motion 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/playhead-reel.tsx"use client"; import * as React from "react"; import { cancelFrame, frame, useMotionValue, useReducedMotion, useScroll, useSpring, } from "motion/react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * PLAYHEAD REEL STORYBOARD (driven by scroll progress, not time) * * rest every item sits small in one strip across the middle, * in groups with a gap between them. A ruler underneath * has a tick per item and a label per group * 0% the section pins; the playhead stands on the first item * 0→100% the playhead walks left → right along the ruler while * the strip slides the other way under it, so the * playhead is also a progress bar for the whole reel * bloom items near the playhead open out of the strip: they * grow to about half the lane, spread apart and drift * above or below the line into a loose collage, then * shrink back as they pass. Later items lie on top, * like prints dealt left to right * skim scroll fast and the collage folds flat into the strip, * so you can see where you're going; slow down and it * opens again * edges near either end the collage leans inward, so the item * under the playhead never hangs off the stage * point hover an open item to lift it off the pile; click any * item to glide it under the playhead * scrub drag along the ruler, or focus the handle and use the * arrow keys (Shift or Page Up/Down jump a group). * Group labels jump to their group * reduce no smoothing and no folding; jumps are instant * * Every item is one element moved by a transform written once a * frame, so scrolling never renders React. * ───────────────────────────────────────────────────────── */ const REEL = { thumbHeight: 40, // px, an item's height in the strip gap: 6, // px between items in a group groupGap: 40, // px between groups bloom: 0.46, // height of an open item, as a fraction of the lane maxWidth: 0.6, // widest an open item gets, as a fraction of the stage sizes: [0.8, 1.08], // open items vary between these sizes lifts: [0.3, 1], // and drift this much of their room off the line room: 0.6, // most room an item gets on each side, as a fraction of its open height reach: 2.5, // roughly how many items either side of the playhead open overlap: 0.55, // open neighbors sit this fraction of an item's width apart maxSpread: 0.25, // cap on how far the collage pushes the strip aside, as a fraction of the stage dim: 0.5, // opacity of strip items away from the playhead skim: [10, 36], // items per second where the collage starts to fold, and is flat settle: 140, // ms without scrolling before a folded collage opens again pad: [16, 32], // px between the ruler and the stage edge, narrow and wide edge: 12, // px kept between an open item and the lane's edges ruler: 48, // px, height of the band under the lane track: 14, // px from the top of the ruler to its line glide: 1200, // ms a keyboard step remembers its target while the scroll catches up }; const SCROLL_SPRING = { stiffness: 170, damping: 32, mass: 0.4 }; const BLOOM_SPRING = { stiffness: 180, damping: 26 }; export interface PlayheadReelItem { /** Image URL. Leave it out and pass `content` to show your own markup instead. */ src?: string; alt?: string; /** Width ÷ height. Sets the item's width in the strip and the shape it opens to. */ aspect?: number; /** Rendered in place of an image, at the item's open size, then scaled with it. */ content?: React.ReactNode; } export interface PlayheadReelGroup { /** Short name on the ruler, like "v2.4", "2019" or "Lisbon". */ label: string; items: readonly PlayheadReelItem[]; } export interface PlayheadReelActive { /** Index of the group under the playhead. */ group: number; /** Index of the item under the playhead, within its group. */ item: number; } export interface PlayheadReelProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> { groups: readonly PlayheadReelGroup[]; /** Height of an item in the strip, in px. */ thumbHeight?: number; /** Pixels of scroll it takes to move the playhead one item along. */ scrollPerItem?: number; /** Roughly how many items on each side of the playhead open up. */ reach?: number; /** Fold the collage flat while scrolling fast, and open it again on slowing down. */ skim?: boolean; /** * Scroll container to track. Defaults to the window. The container must be * positioned (e.g. `relative`), or scroll progress is measured from the page. */ containerRef?: React.RefObject<HTMLElement | null>; /** Classes for the pinned stage. Set its height and text color here. */ stageClassName?: string; /** Classes for every item's frame, which defaults to rounded corners and a soft shadow. */ itemClassName?: string; /** Pinned above the reel, in the flow: titles, counters, links. */ header?: React.ReactNode; /** Pinned below the ruler, in the flow. */ footer?: React.ReactNode; /** Called with the group and item under the playhead whenever they change. */ onActiveChange?: (active: PlayheadReelActive) => void; } type Slot = { group: number; index: number; aspect: number; /** Center along the strip at rest, in px. */ center: number; /** Open-size factor. */ size: number; /** Where it drifts when open: −1 is the top of its room, 1 the bottom. */ lift: number; }; type Strip = { slots: Slot[]; /** First item of each group that has any, in order. */ starts: { group: number; slot: number }[]; first: number; length: number; /** Average center-to-center distance, gaps included. */ pitch: number; }; type Geometry = { width: number; lane: number; /** Where the playhead starts, and how far it walks. */ x0: number; travel: number; /** Open height and vertical room of each item. */ open: number[]; room: number[]; radius: number; spread: number; /** Half the width of each item when open. */ half: number[]; }; export function PlayheadReel({ groups, thumbHeight = REEL.thumbHeight, scrollPerItem = 80, reach = REEL.reach, skim = true, containerRef, stageClassName, itemClassName, header, footer, onActiveChange, className, ...props }: PlayheadReelProps) { const sectionRef = React.useRef<HTMLElement>(null); const deckRef = React.useRef<HTMLDivElement>(null); const playheadRef = React.useRef<HTMLDivElement>(null); const handleRef = React.useRef<HTMLDivElement>(null); const fillRef = React.useRef<HTMLDivElement>(null); const itemEls = React.useRef<(HTMLDivElement | null)[]>([]); const activeRef = React.useRef(0); const pendingRef = React.useRef<{ slot: number; until: number } | null>(null); const reduceMotion = useReducedMotion() ?? false; const strip = React.useMemo(() => layoutStrip(groups, thumbHeight), [groups, thumbHeight]); const deck = useElementSize(deckRef); const geometry = React.useMemo( () => measureReel(strip, deck.width, deck.height - REEL.ruler, thumbHeight, reach), [strip, deck.width, deck.height, thumbHeight, reach], ); const near = useNearViewport(sectionRef); const [active, setActive] = React.useState(0); const { scrollYProgress } = useScroll({ target: sectionRef, container: containerRef, offset: ["start start", "end end"], }); const smoothProgress = useSpring(scrollYProgress, SCROLL_SPRING); const progress = reduceMotion ? scrollYProgress : smoothProgress; const bloomTarget = useMotionValue(1); const bloom = useSpring(bloomTarget, BLOOM_SPRING); const report = React.useEffectEvent((slot: number) => { setActive(slot); const { group, index } = strip.slots[slot]; onActiveChange?.({ group, item: index }); }); const setItemEl = React.useCallback((index: number, el: HTMLDivElement | null) => { itemEls.current[index] = el; }, []); React.useLayoutEffect(() => { const g = geometry; if (!g) return; const { slots, first, length } = strip; const skimming = skim && !reduceMotion; if (!skimming) bloomTarget.set(1); let settle: ReturnType<typeof setTimeout> | undefined; const paint = () => { const p = clamp01(progress.get()); const b = bloom.get(); const focus = first + p * length; const x = g.x0 + p * g.travel; // Lean the collage inward near the ends, so the item under the playhead stays on stage. const reachOut = halfAt(slots, g.half, focus) * b + REEL.edge; const lean = Math.max(0, reachOut - x) - Math.max(0, x + reachOut - g.width); for (let i = 0; i < slots.length; i++) { const el = itemEls.current[i]; if (!el) continue; const slot = slots[i]; const d = slot.center - focus; const closeness = Math.exp(-(d * d) / (g.radius * g.radius)); const open = b * closeness; const cx = x + lean + d + g.spread * b * Math.tanh(d / g.radius); const height = thumbHeight + (g.open[i] - thumbHeight) * open; const half = (height * slot.aspect) / 2; if (cx + half < 0 || cx - half > g.width) { el.style.visibility = "hidden"; continue; } const cy = slot.lift * g.room[i] * open; const w = g.open[i] * slot.aspect; el.style.visibility = "visible"; // Only the strip dims; anything opening up is fully opaque, so overlaps stay clean. el.style.opacity = String(REEL.dim + (1 - REEL.dim) * Math.min(1, closeness * 3)); el.style.transform = `translate3d(${cx - w / 2}px, ${cy - g.open[i] / 2}px, 0) scale(${height / g.open[i]})`; } const playhead = `translate3d(${x}px, 0, 0)`; if (playheadRef.current) playheadRef.current.style.transform = playhead; if (handleRef.current) handleRef.current.style.transform = playhead; if (fillRef.current) fillRef.current.style.transform = `scaleX(${g.travel ? (x - g.x0) / g.travel : 0})`; const current = nearestSlot(slots, focus); const pending = pendingRef.current; if (pending && (pending.slot === current || performance.now() > pending.until)) { pendingRef.current = null; } if (current !== activeRef.current) { activeRef.current = current; report(current); } }; const schedule = () => frame.render(paint); const onProgress = () => { if (skimming && strip.pitch > 0) { const speed = (Math.abs(progress.getVelocity()) * length) / strip.pitch; bloomTarget.set(1 - smoothstep(REEL.skim[0], REEL.skim[1], speed)); clearTimeout(settle); settle = setTimeout(() => bloomTarget.set(1), REEL.settle); } schedule(); }; paint(); const stops = [progress.on("change", onProgress), bloom.on("change", schedule)]; return () => { stops.forEach((stop) => stop()); cancelFrame(paint); clearTimeout(settle); }; }, [bloom, bloomTarget, geometry, progress, reduceMotion, skim, strip, thumbHeight]); const scrollToProgress = (p: number, smooth: boolean) => { const section = sectionRef.current; if (!section) return; const container = containerRef?.current ?? null; const viewport = container ? container.clientHeight : window.innerHeight; const range = Math.max(0, section.offsetHeight - viewport); const origin = container ? container.getBoundingClientRect().top + container.clientTop : 0; const scrolled = container ? container.scrollTop : window.scrollY; const top = scrolled + section.getBoundingClientRect().top - origin + clamp01(p) * range; (container ?? window).scrollTo({ top, behavior: smooth && !reduceMotion ? "smooth" : "instant" }); }; const glideTo = (slot: number) => { const target = Math.min(Math.max(slot, 0), strip.slots.length - 1); if (target < 0) return; pendingRef.current = { slot: target, until: performance.now() + REEL.glide }; scrollToProgress(strip.length ? (strip.slots[target].center - strip.first) / strip.length : 0, true); }; const scrubTo = (clientX: number) => { const el = deckRef.current; if (!el || !geometry?.travel) return; const x = clientX - el.getBoundingClientRect().left; pendingRef.current = null; scrollToProgress((x - geometry.x0) / geometry.travel, false); }; const onKeyDown = (event: React.KeyboardEvent) => { const { slots, starts } = strip; if (!slots.length) return; const from = pendingRef.current?.slot ?? activeRef.current; const at = starts.findLastIndex((start) => start.slot <= from); const nextGroup = () => starts[at + 1]?.slot ?? slots.length - 1; const previousGroup = () => starts[at].slot < from ? starts[at].slot : (starts[at - 1]?.slot ?? 0); let to: number | null = null; if (event.key === "ArrowRight" || event.key === "ArrowDown") { to = event.shiftKey ? nextGroup() : from + 1; } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { to = event.shiftKey ? previousGroup() : from - 1; } else if (event.key === "PageDown") to = nextGroup(); else if (event.key === "PageUp") to = previousGroup(); else if (event.key === "Home") to = 0; else if (event.key === "End") to = slots.length - 1; if (to === null) return; event.preventDefault(); glideTo(to); }; const activeSlot = strip.slots[active]; const activeGroup = activeSlot ? groups[activeSlot.group] : undefined; const lane = geometry?.lane ?? 0; const tickX = (slot: number) => geometry ? geometry.x0 + (strip.length ? (strip.slots[slot].center - strip.first) / strip.length : 0) * geometry.travel : 0; return ( <section ref={sectionRef} className={cn("relative", className)} {...props}> <div className={cn("sticky top-0 flex h-dvh w-full flex-col overflow-hidden", stageClassName)}> {header} <div ref={deckRef} className="relative min-h-0 flex-1" // Hidden until measured, so items don't flash in a pile at the corner. style={{ visibility: geometry ? undefined : "hidden" }} > <div className="absolute inset-x-0 top-0 isolate" style={{ height: lane }} onClick={(event) => { const item = (event.target as Element).closest<HTMLElement>("[data-reel-item]"); if (item) glideTo(Number(item.dataset.reelItem)); }} > {geometry ? ( <ReelItems groups={groups} strip={strip} open={geometry.open} load={near} className={itemClassName} setItemEl={setItemEl} /> ) : null} </div> <div ref={playheadRef} aria-hidden className="pointer-events-none absolute top-0 left-0 z-10 w-px bg-current will-change-transform" style={{ height: lane + REEL.track }} > <span className="absolute top-0 left-1/2 size-[5px] -translate-x-1/2 rounded-full bg-current" /> </div> {geometry ? ( <div className="absolute inset-x-0 bottom-0" style={{ height: REEL.ruler }}> <div aria-hidden className="absolute h-px bg-current opacity-15" style={{ left: geometry.x0, width: geometry.travel, top: REEL.track }} /> <div ref={fillRef} aria-hidden className="absolute h-px origin-left bg-current opacity-70" style={{ left: geometry.x0, width: geometry.travel, top: REEL.track }} /> <svg aria-hidden className="pointer-events-none absolute inset-0 size-full overflow-visible"> <path d={strip.slots.map((_, i) => `M${tickX(i)} ${REEL.track - 5}v5`).join("")} stroke="currentColor" strokeOpacity={0.3} /> <path d={strip.starts.map(({ slot }) => `M${tickX(slot)} ${REEL.track - 8}v16`).join("")} stroke="currentColor" strokeOpacity={0.6} /> </svg> {/* Drag anywhere along the line to scrub; the handle rides inside. */} <div className="absolute inset-x-0 top-0 h-7 cursor-ew-resize touch-none" onPointerDown={(event) => { if (event.button !== 0) return; event.currentTarget.setPointerCapture(event.pointerId); handleRef.current?.querySelector<HTMLElement>("[role=slider]")?.focus({ preventScroll: true }); scrubTo(event.clientX); }} onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) scrubTo(event.clientX); }} > <div ref={handleRef} className="absolute top-0 left-0 size-0 will-change-transform" style={{ top: REEL.track }}> <div role="slider" tabIndex={0} aria-label="Playhead" aria-valuemin={0} aria-valuemax={Math.max(strip.slots.length - 1, 0)} aria-valuenow={active} aria-valuetext={ activeSlot && activeGroup ? `${activeGroup.label}, ${ activeGroup.items[activeSlot.index]?.alt || `${activeSlot.index + 1} of ${activeGroup.items.length}` }` : undefined } onKeyDown={onKeyDown} className="absolute h-4 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current outline-current outline-offset-2 focus-visible:outline-2 before:absolute before:-inset-x-3 before:-inset-y-2" /> </div> </div> {strip.starts.map(({ group, slot }, k) => { const left = tickX(slot); const next = strip.starts[k + 1]; const right = next ? tickX(next.slot) - 8 : geometry.width - 8; const room = right - left - 4; if (room < 24) return null; return ( <button key={group} type="button" data-active={activeSlot?.group === group ? "" : undefined} onClick={() => glideTo(slot)} className="absolute truncate rounded-sm px-0.5 text-left text-[11px] leading-4 font-medium opacity-45 transition-opacity duration-200 outline-current hover:opacity-100 focus-visible:outline-2 data-active:opacity-100" style={{ left: left + 2, top: REEL.track + 10, maxWidth: room }} > {groups[group].label} </button> ); })} </div> ) : null} </div> {footer} </div> {/* Scroll runway: the stage stays pinned while this scrolls past. */} <div aria-hidden style={{ height: strip.pitch ? (strip.length / strip.pitch) * scrollPerItem : 0 }} /> </section> ); } /** * Every item, at its open size. The paint loop scales each one down to the * strip and back, so this only renders again when the stage resizes. */ const ReelItems = React.memo(function ReelItems({ groups, strip, open, load, className, setItemEl, }: { groups: readonly PlayheadReelGroup[]; strip: Strip; open: number[]; load: boolean; className?: string; setItemEl: (index: number, el: HTMLDivElement | null) => void; }) { return strip.slots.map((slot, i) => { const item = groups[slot.group].items[slot.index]; const height = open[i]; return ( <div key={`${slot.group}-${slot.index}`} ref={(el) => setItemEl(i, el)} data-reel-item={i} className="group/item absolute top-1/2 left-0 z-(--z) cursor-pointer will-change-transform hover:z-[10000]" style={{ width: height * slot.aspect, height, "--z": i } as React.CSSProperties} > <div className={cn( "size-full overflow-hidden rounded-lg bg-current/8 shadow-[0_12px_32px_-14px_rgb(0_0_0/0.4)] transition-[scale,box-shadow] duration-300 ease-out group-hover/item:shadow-[0_28px_56px_-18px_rgb(0_0_0/0.5)] motion-safe:group-hover/item:scale-[1.035]", className, )} > {item.content ?? (item.src && load ? ( <img src={item.src} alt={item.alt ?? ""} draggable={false} decoding="async" className="size-full object-cover" /> ) : null)} </div> </div> ); }); }); function layoutStrip(groups: readonly PlayheadReelGroup[], thumbHeight: number): Strip { const slots: Slot[] = []; const starts: Strip["starts"] = []; let x = 0; groups.forEach((group, g) => { if (!group.items.length) return; if (slots.length) x += REEL.groupGap - REEL.gap; starts.push({ group: g, slot: slots.length }); group.items.forEach((item, index) => { const aspect = item.aspect ?? 4 / 3; const width = thumbHeight * aspect; const n = slots.length; slots.push({ group: g, index, aspect, center: x + width / 2, size: lerp(REEL.sizes[0], REEL.sizes[1], hash(n + 0.5)), // Alternate above and below the line, by a different amount each time. lift: (n % 2 ? 1 : -1) * lerp(REEL.lifts[0], REEL.lifts[1], hash(n)), }); x += width + REEL.gap; }); }); const first = slots[0]?.center ?? 0; const length = (slots.at(-1)?.center ?? 0) - first; return { slots, starts, first, length, pitch: slots.length > 1 ? length / (slots.length - 1) : 0 }; } function measureReel( strip: Strip, width: number, lane: number, thumbHeight: number, reach: number, ): Geometry | null { if (!width || lane <= 0 || !strip.slots.length) return null; const pad = width < 640 ? REEL.pad[0] : REEL.pad[1]; // A short reel doesn't stretch: the strip stays put and the playhead walks its length. const travel = Math.min(Math.max(0, width - 2 * pad), strip.length); const target = lane * REEL.bloom; const open = strip.slots.map((slot) => Math.max( thumbHeight, Math.min(target * slot.size, lane - 2 * REEL.edge, (width * REEL.maxWidth) / slot.aspect), ), ); // On tall stages, stay near the line rather than scattering to the edges. const room = open.map((height) => Math.max(0, Math.min((lane - height) / 2 - REEL.edge, height * REEL.room)), ); const half = open.map((height, i) => (height * strip.slots[i].aspect) / 2); const meanWidth = (2 * half.reduce((sum, h) => sum + h, 0)) / half.length; const pitch = strip.pitch || thumbHeight; const radius = Math.max(1, reach * pitch); // Spread neighbors so they sit `overlap` of a width apart at the playhead: // the strip's slope there is 1 + spread / radius. const spread = Math.min( radius * Math.max(0, (REEL.overlap * meanWidth) / pitch - 1), width * REEL.maxSpread, ); return { width, lane, x0: (width - travel) / 2, travel, open, room, radius, spread, half, }; } /** Half-width of the open item at `x` along the strip, blended between neighbors. */ function halfAt(slots: Slot[], half: number[], x: number) { const next = slots.findIndex((slot) => slot.center >= x); if (next <= 0) return half[next < 0 ? half.length - 1 : 0]; const t = (x - slots[next - 1].center) / (slots[next].center - slots[next - 1].center); return lerp(half[next - 1], half[next], t); } function nearestSlot(slots: Slot[], x: number) { let lo = 0; let hi = slots.length - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (slots[mid].center < x) lo = mid + 1; else hi = mid; } return lo > 0 && x - slots[lo - 1].center < slots[lo].center - x ? lo - 1 : lo; } function useElementSize(ref: React.RefObject<HTMLElement | null>) { const [size, setSize] = React.useState({ width: 0, height: 0 }); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; const update = () => setSize({ width: el.clientWidth, height: el.clientHeight }); update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, [ref]); return size; } /** True once the element comes within a screen of the viewport, and stays true. */ function useNearViewport(ref: React.RefObject<HTMLElement | null>) { const [near, setNear] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el || near) return; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; setNear(true); observer.disconnect(); }, { rootMargin: "100% 0px" }, ); observer.observe(el); return () => observer.disconnect(); }, [ref, near]); return near; } function hash(n: number) { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); } function lerp(a: number, b: number, t: number) { return a + (b - a) * t; } function clamp01(v: number) { return v < 0 ? 0 : v > 1 ? 1 : v; } function smoothstep(a: number, b: number, v: number) { const x = clamp01((v - a) / (b - a)); return x * x * (3 - 2 * x); }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 Playhead Reel. Each one installs whole, with the component included.
Changelog
Six releases as live UI mockups rather than screenshots, so they stay sharp and follow your theme. The header shows the version and date, and a caption for the screen under the playhead. Tab to the playhead and use the arrow keys: Shift jumps a release.
A faster editor
Pages open instantly, even with thousands of blocks.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { PlayheadReel, type PlayheadReelGroup } from "@/components/ui/playhead-reel";
type Accent = { badge: string; solid: string; soft: string; text: string };
const accents = {
indigo: {
badge: "bg-indigo-500/10 text-indigo-700 dark:text-indigo-300",
solid: "bg-indigo-500",
soft: "bg-indigo-500/15",
text: "text-indigo-500",
},
emerald: {
badge: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
solid: "bg-emerald-500",
soft: "bg-emerald-500/15",
text: "text-emerald-500",
},
amber: {
badge: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
solid: "bg-amber-500",
soft: "bg-amber-500/15",
text: "text-amber-500",
},
rose: {
badge: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
solid: "bg-rose-500",
soft: "bg-rose-500/15",
text: "text-rose-500",
},
sky: {
badge: "bg-sky-500/10 text-sky-700 dark:text-sky-300",
solid: "bg-sky-500",
soft: "bg-sky-500/15",
text: "text-sky-500",
},
violet: {
badge: "bg-violet-500/10 text-violet-700 dark:text-violet-300",
solid: "bg-violet-500",
soft: "bg-violet-500/15",
text: "text-violet-500",
},
} satisfies Record<string, Accent>;
type Release = PlayheadReelGroup & { title: string; date: string; accent: Accent };
const screen = (alt: string, content: React.ReactNode, aspect = 4 / 3) => ({ alt, content, aspect });
const releases: Release[] = [
{
label: "v2.0",
title: "A faster editor",
date: "January 2025",
accent: accents.indigo,
items: [
screen("Pages open instantly, even with thousands of blocks.", <EditorScreen accent={accents.indigo} />),
screen("Type / to insert any block.", <SlashScreen accent={accents.indigo} />),
screen("The editor now works on phones.", <PhoneScreen accent={accents.indigo} />, 3 / 4),
],
},
{
label: "v2.1",
title: "Comments",
date: "March 2025",
accent: accents.emerald,
items: [
screen("Comment on any block and reply in threads.", <ThreadScreen accent={accents.emerald} />),
screen("@mention a teammate to pull them in.", <MentionsScreen accent={accents.emerald} />, 3 / 4),
screen("Resolve a thread when it's done.", <ResolveScreen accent={accents.emerald} />),
],
},
{
label: "v2.2",
title: "Insights",
date: "May 2025",
accent: accents.amber,
items: [
screen("See who's active each week.", <BarsScreen accent={accents.amber} />),
screen("Spot trends over any range.", <TrendScreen accent={accents.amber} />, 3 / 2),
screen("Export any report as CSV, PDF or PNG.", <ExportScreen accent={accents.amber} />, 1),
],
},
{
label: "v2.3",
title: "Boards",
date: "July 2025",
accent: accents.rose,
items: [
screen("Drag work across columns.", <BoardScreen accent={accents.rose} />, 3 / 2),
screen("See every deadline on a calendar.", <CalendarScreen accent={accents.rose} />, 1),
screen("Plan projects on a timeline.", <TimelineScreen accent={accents.rose} />),
],
},
{
label: "v2.4",
title: "Command palette",
date: "September 2025",
accent: accents.sky,
items: [
screen("Search pages, actions and settings from ⌘K.", <PaletteScreen accent={accents.sky} />),
screen("Every action has a shortcut.", <ShortcutsScreen />, 3 / 4),
screen("Dark mode follows your system.", <ThemeScreen accent={accents.sky} />),
],
},
{
label: "v2.5",
title: "Teams",
date: "November 2025",
accent: accents.violet,
items: [
screen("Roles for owners, admins and members.", <MembersScreen accent={accents.violet} />),
screen("Invite a whole team at once.", <InviteScreen accent={accents.violet} />, 1),
screen("An audit log of every change.", <AuditScreen />, 3 / 2),
],
},
];
export function PlayheadReelChangelogDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [view, setView] = React.useState({ group: 0, item: 0, direction: 1 });
const release = releases[view.group];
const caption = release.items[view.item]?.alt;
return (
<div className="relative w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
<div ref={scrollRef} className="relative h-[var(--demo-height,38rem)] overflow-y-auto overscroll-contain">
<PlayheadReel
groups={releases}
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,38rem)]"
itemClassName="rounded-xl ring-1 ring-zinc-900/10 dark:ring-white/10"
thumbHeight={36}
onActiveChange={({ group, item }) =>
setView((prev) => ({
group,
item,
direction: group * 100 + item >= prev.group * 100 + prev.item ? 1 : -1,
}))
}
header={
<header className="px-4 pt-5 pb-4 sm:px-8 sm:pt-7">
<div className="flex items-center justify-between gap-4 font-mono text-[11px] tabular-nums">
<div className="flex items-center gap-2">
<span className="tracking-widest text-zinc-500 uppercase">Changelog</span>
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium transition-colors duration-300",
release.accent.badge,
)}
>
{release.label}
</span>
</div>
<span className="text-zinc-500">{release.date}</span>
</div>
<Roll id={view.group} direction={view.direction} className="mt-2">
<h2 className="truncate text-2xl leading-[1.2] font-semibold tracking-tight sm:text-4xl">
{release.title}
</h2>
</Roll>
<Roll id={view.group * 100 + view.item} direction={view.direction} className="mt-1">
<p className="truncate text-sm leading-6 text-zinc-500">{caption}</p>
</Roll>
</header>
}
/>
</div>
</div>
);
}
/** Rolls its content out and the next one in, up when moving forward and down when going back. */
function Roll({
id,
direction,
className,
children,
}: {
id: number;
direction: number;
className?: string;
children: React.ReactNode;
}) {
const reduceMotion = useReducedMotion();
return (
<div className={cn("relative overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={id}
custom={direction}
variants={{
enter: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * 100}%` }),
center: { y: 0, opacity: 1 },
exit: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * -100}%` }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduceMotion ? 0.2 : 0.5, ease: [0.23, 1, 0.32, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
}
/* ── Screens ─────────────────────────────────────────────
* Small product mockups in plain markup. Each fills its item
* at the item's open size, so text stays crisp when open. */
function Window({ title, children, className }: { title?: string; children: React.ReactNode; className?: string }) {
return (
<div className="flex size-full flex-col bg-white text-[10px] leading-tight text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100">
<div className="flex shrink-0 items-center gap-1 border-b border-zinc-200 px-2.5 py-1.5 dark:border-zinc-800">
<span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
{title ? <span className="ml-1.5 truncate text-[9px] text-zinc-400">{title}</span> : null}
</div>
<div className={cn("relative min-h-0 flex-1 overflow-hidden p-3", className)}>{children}</div>
</div>
);
}
function Bar({ width, className }: { width: string; className?: string }) {
return (
<span
className={cn("block h-1.5 shrink-0 rounded-full bg-zinc-200 dark:bg-zinc-800", className)}
style={{ width }}
/>
);
}
function Avatar({ name, className }: { name: string; className: string }) {
return (
<span
className={cn(
"grid size-5 shrink-0 place-items-center rounded-full text-[8px] font-semibold text-white",
className,
)}
>
{name}
</span>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded border border-zinc-200 bg-zinc-50 px-1 font-mono text-[8px] text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800">
{children}
</kbd>
);
}
function EditorScreen({ accent }: { accent: Accent }) {
return (
<Window title="Launch plan">
<div className="flex gap-1">
{["B", "I", "U", "H1", "H2", "“ ”"].map((tool) => (
<span
key={tool}
className="grid h-4 min-w-4 place-items-center rounded bg-zinc-100 px-1 text-[8px] font-semibold text-zinc-500 dark:bg-zinc-800"
>
{tool}
</span>
))}
</div>
<p className="mt-3 text-[14px] font-semibold tracking-tight">Launch plan</p>
<div className="mt-2 space-y-1.5">
<Bar width="94%" />
<Bar width="82%" />
<Bar width="88%" />
</div>
<div className="mt-2 flex items-center gap-1">
<Bar width="46%" className={accent.soft} />
<span className={cn("h-3 w-px", accent.solid)} />
</div>
</Window>
);
}
function SlashScreen({ accent }: { accent: Accent }) {
const blocks = ["Heading", "To-do list", "Image", "Code", "Quote"];
return (
<Window title="Launch plan">
<div className="space-y-1.5">
<Bar width="78%" />
<Bar width="64%" />
</div>
<p className="mt-2 font-mono text-zinc-400">/</p>
<div className="absolute top-12 left-6 w-32 rounded-lg border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
{blocks.map((block, i) => (
<div
key={block}
className={cn("flex items-center gap-1.5 rounded px-1.5 py-1", i === 1 && accent.soft)}
>
<span className="size-3 rounded-sm border border-zinc-300 dark:border-zinc-600" />
{block}
</div>
))}
</div>
</Window>
);
}
function PhoneScreen({ accent }: { accent: Accent }) {
return (
<div className="grid size-full place-items-center bg-zinc-100 p-4 dark:bg-zinc-800">
<div className="flex h-full max-h-80 w-full max-w-40 flex-col gap-2 rounded-[1.25rem] border-4 border-zinc-900 bg-white p-3 text-[10px] dark:border-zinc-950 dark:bg-zinc-900">
<span className="mx-auto h-1 w-8 rounded-full bg-zinc-300 dark:bg-zinc-700" />
<p className="mt-1 text-[12px] font-semibold tracking-tight">Launch plan</p>
<Bar width="92%" />
<Bar width="76%" />
<Bar width="84%" />
<div className="mt-auto flex justify-between">
<span className={cn("h-5 w-14 rounded-full", accent.soft)} />
<span className={cn("size-5 rounded-full", accent.solid)} />
</div>
</div>
</div>
);
}
function ThreadScreen({ accent }: { accent: Accent }) {
const comments = [
{ who: "MA", name: "Maya", color: "bg-orange-400", widths: ["80%", "56%"] },
{ who: "JO", name: "Jonas", color: "bg-sky-500", widths: ["68%"] },
];
return (
<Window title="Comments">
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.who} className="flex gap-2">
<Avatar name={comment.who} className={comment.color} />
<div className="min-w-0 flex-1 space-y-1">
<p className="font-semibold">
{comment.name} <span className="font-normal text-zinc-400">2m</span>
</p>
{comment.widths.map((width) => (
<Bar key={width} width={width} />
))}
</div>
</div>
))}
<div className="flex items-center gap-2 rounded-md border border-zinc-200 px-2 py-1.5 dark:border-zinc-700">
<span className="text-zinc-400">Reply…</span>
<span className={cn("ml-auto rounded px-1.5 py-0.5 font-medium text-white", accent.solid)}>Send</span>
</div>
</div>
</Window>
);
}
function MentionsScreen({ accent }: { accent: Accent }) {
const notes = [
{ who: "MA", color: "bg-orange-400", text: "Maya mentioned you" },
{ who: "JO", color: "bg-sky-500", text: "Jonas replied" },
{ who: "RI", color: "bg-fuchsia-500", text: "Rin mentioned you" },
];
return (
<div className="flex size-full flex-col justify-center gap-2 bg-zinc-100 p-3 text-[10px] dark:bg-zinc-800">
{notes.map((note, i) => (
<div
key={note.who}
className="flex items-center gap-2 rounded-lg bg-white p-2 shadow-sm dark:bg-zinc-900"
style={{ opacity: 1 - i * 0.2 }}
>
<Avatar name={note.who} className={note.color} />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{note.text}</p>
<p className="truncate text-zinc-400">in Launch plan</p>
</div>
{i === 0 ? <span className={cn("size-1.5 rounded-full", accent.solid)} /> : null}
</div>
))}
</div>
);
}
function ResolveScreen({ accent }: { accent: Accent }) {
return (
<Window title="Comments">
<div className="rounded-lg border border-zinc-200 p-2.5 dark:border-zinc-700">
<div className="flex items-center gap-2">
<Avatar name="JO" className="bg-sky-500" />
<p className="font-semibold">Jonas</p>
<span className={cn("ml-auto flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium", accent.badge)}>
<svg viewBox="0 0 12 12" className="size-2.5" aria-hidden>
<path d="M2.5 6.5l2.2 2L9.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.6" />
</svg>
Resolved
</span>
</div>
<div className="mt-2 space-y-1.5 opacity-50">
<Bar width="86%" />
<Bar width="60%" />
</div>
</div>
</Window>
);
}
function BarsScreen({ accent }: { accent: Accent }) {
const heights = [38, 52, 44, 61, 58, 70, 66, 74, 69, 82, 78, 92];
return (
<Window title="Insights">
<div className="flex h-full flex-col">
<p className="text-zinc-400">Weekly active</p>
<div className="flex items-baseline gap-1.5">
<p className="text-[18px] font-semibold tracking-tight tabular-nums">12,480</p>
<span className={cn("rounded-full px-1.5 font-medium", accent.badge)}>+8.2%</span>
</div>
<div className="mt-2 flex min-h-0 flex-1 items-end gap-1">
{heights.map((height, i) => (
<span
key={i}
className={cn("flex-1 rounded-sm", i === heights.length - 1 ? accent.solid : accent.soft)}
style={{ height: `${height}%` }}
/>
))}
</div>
</div>
</Window>
);
}
function TrendScreen({ accent }: { accent: Accent }) {
const points = [62, 58, 64, 55, 49, 52, 44, 38, 42, 33, 28, 30, 22, 18];
const line = points.map((y, i) => `${(i / (points.length - 1)) * 100},${y}`).join(" ");
return (
<Window title="Insights · Last 90 days">
<div className="flex h-full flex-col">
<div className="flex gap-1">
{["7d", "30d", "90d"].map((range) => (
<span
key={range}
className={cn(
"rounded px-1.5 py-0.5 font-medium",
range === "90d" ? accent.badge : "text-zinc-400",
)}
>
{range}
</span>
))}
</div>
<svg viewBox="0 0 100 70" preserveAspectRatio="none" className={cn("mt-2 min-h-0 w-full flex-1", accent.text)} aria-hidden>
<polygon points={`0,70 ${line} 100,70`} fill="currentColor" opacity="0.12" />
<polyline points={line} fill="none" stroke="currentColor" strokeWidth="1.5" vectorEffect="non-scaling-stroke" />
</svg>
</div>
</Window>
);
}
function ExportScreen({ accent }: { accent: Accent }) {
const formats = ["CSV", "PDF", "PNG"];
return (
<div className="grid size-full place-items-center bg-zinc-100 p-4 text-[10px] dark:bg-zinc-800">
<div className="w-full max-w-48 rounded-xl bg-white p-3 shadow-lg dark:bg-zinc-900">
<p className="text-[12px] font-semibold">Export report</p>
<div className="mt-2 space-y-1">
{formats.map((format, i) => (
<div
key={format}
className={cn(
"flex items-center gap-2 rounded-md border px-2 py-1.5",
i === 1 ? "border-transparent " + accent.soft : "border-zinc-200 dark:border-zinc-700",
)}
>
<span
className={cn(
"size-2.5 rounded-full border",
i === 1 ? "border-4 border-current " + accent.text : "border-zinc-300 dark:border-zinc-600",
)}
/>
{format}
</div>
))}
</div>
<div className={cn("mt-2.5 rounded-md py-1.5 text-center font-medium text-white", accent.solid)}>Export</div>
</div>
</div>
);
}
function BoardScreen({ accent }: { accent: Accent }) {
const columns = [
{ name: "To do", cards: 3 },
{ name: "Doing", cards: 2 },
{ name: "Done", cards: 2 },
];
return (
<Window title="Boards · Website">
<div className="grid grid-cols-3 items-start gap-2">
{columns.map((column, c) => (
<div key={column.name} className="space-y-1.5 rounded-lg bg-zinc-100 p-1.5 dark:bg-zinc-800">
<p className="font-semibold text-zinc-500">{column.name}</p>
{Array.from({ length: column.cards }, (_, i) => (
<div key={i} className="space-y-1 rounded-md bg-white p-1.5 shadow-sm dark:bg-zinc-900">
<Bar width={`${70 + ((i + c) % 3) * 10}%`} />
{c === 1 && i === 0 ? <span className={cn("block h-1.5 w-8 rounded-full", accent.solid)} /> : null}
</div>
))}
</div>
))}
</div>
</Window>
);
}
function CalendarScreen({ accent }: { accent: Accent }) {
const events: Record<number, string> = { 4: "Review", 11: "Launch", 17: "Retro", 24: "Ship" };
return (
<Window title="July">
<div className="grid grid-cols-7 gap-0.5 text-center text-[8px]">
{["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
<span key={i} className="pb-0.5 text-zinc-400">
{day}
</span>
))}
{Array.from({ length: 28 }, (_, i) => (
<div key={i} className="flex aspect-square flex-col items-center gap-0.5 rounded bg-zinc-50 pt-0.5 dark:bg-zinc-800/60">
<span className="text-zinc-500 tabular-nums">{i + 1}</span>
{events[i + 1] ? <span className={cn("h-1 w-3/4 rounded-full", accent.solid)} /> : null}
</div>
))}
</div>
</Window>
);
}
function TimelineScreen({ accent }: { accent: Accent }) {
const rows = [
{ name: "Research", start: 0, span: 30 },
{ name: "Design", start: 22, span: 34 },
{ name: "Build", start: 48, span: 40 },
{ name: "Launch", start: 84, span: 14 },
];
return (
<Window title="Timeline · Q3">
<div className="space-y-2.5">
{rows.map((row, i) => (
<div key={row.name} className="flex items-center gap-2">
<span className="w-12 shrink-0 text-zinc-500">{row.name}</span>
<div className="relative h-3 flex-1 rounded bg-zinc-100 dark:bg-zinc-800">
<span
className={cn("absolute inset-y-0 rounded", i === 2 ? accent.solid : accent.soft)}
style={{ left: `${row.start}%`, width: `${row.span}%` }}
/>
</div>
</div>
))}
</div>
</Window>
);
}
function PaletteScreen({ accent }: { accent: Accent }) {
const actions = ["New document", "Go to Insights", "Invite people", "Toggle dark mode"];
return (
<div className="grid size-full place-items-center bg-zinc-900/5 p-4 text-[10px] dark:bg-black/40">
<div className="w-full max-w-56 overflow-hidden rounded-xl bg-white shadow-xl ring-1 ring-zinc-900/10 dark:bg-zinc-900 dark:ring-white/10">
<div className="flex items-center gap-2 border-b border-zinc-200 px-2.5 py-2 dark:border-zinc-800">
<span className="text-zinc-400">Search or jump to…</span>
<span className="ml-auto">
<Kbd>⌘K</Kbd>
</span>
</div>
<div className="p-1">
{actions.map((action, i) => (
<div key={action} className={cn("flex items-center gap-2 rounded-md px-2 py-1.5", i === 0 && accent.soft)}>
<span className="size-2.5 rounded-sm border border-zinc-300 dark:border-zinc-600" />
{action}
</div>
))}
</div>
</div>
</div>
);
}
function ShortcutsScreen() {
const shortcuts = [
["Search", ["⌘", "K"]],
["New page", ["⌘", "N"]],
["Comment", ["⌘", "⇧", "M"]],
["Bold", ["⌘", "B"]],
["Go to board", ["G", "B"]],
["Help", ["?"]],
] as const;
return (
<Window title="Shortcuts">
<div className="space-y-2">
{shortcuts.map(([name, keys]) => (
<div key={name} className="flex items-center justify-between gap-2">
<span>{name}</span>
<span className="flex gap-0.5">
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
</div>
))}
</div>
</Window>
);
}
function ThemeScreen({ accent }: { accent: Accent }) {
return (
<div className="grid size-full grid-cols-2 text-[10px]">
{[false, true].map((dark) => (
<div
key={String(dark)}
className={cn("flex flex-col gap-1.5 p-3", dark ? "bg-zinc-900 text-zinc-100" : "bg-white text-zinc-900")}
>
<p className="font-semibold">{dark ? "Dark" : "Light"}</p>
<span className={cn("block h-1.5 w-11/12 rounded-full", dark ? "bg-zinc-700" : "bg-zinc-200")} />
<span className={cn("block h-1.5 w-2/3 rounded-full", dark ? "bg-zinc-700" : "bg-zinc-200")} />
<span className={cn("mt-auto block h-4 w-10 rounded-full", accent.solid)} />
</div>
))}
</div>
);
}
function MembersScreen({ accent }: { accent: Accent }) {
const members = [
{ who: "MA", name: "Maya Ito", color: "bg-orange-400", role: "Owner" },
{ who: "JO", name: "Jonas Berg", color: "bg-sky-500", role: "Admin" },
{ who: "RI", name: "Rin Okafor", color: "bg-fuchsia-500", role: "Member" },
{ who: "LU", name: "Luca Sato", color: "bg-emerald-500", role: "Member" },
];
return (
<Window title="Members">
<div className="space-y-2">
{members.map((member) => (
<div key={member.who} className="flex items-center gap-2">
<Avatar name={member.who} className={member.color} />
<span className="min-w-0 flex-1 truncate font-medium">{member.name}</span>
<span
className={cn(
"rounded-full px-1.5 py-0.5",
member.role === "Owner" ? accent.badge : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800",
)}
>
{member.role}
</span>
</div>
))}
</div>
</Window>
);
}
function InviteScreen({ accent }: { accent: Accent }) {
return (
<div className="grid size-full place-items-center bg-zinc-100 p-4 text-[10px] dark:bg-zinc-800">
<div className="w-full max-w-52 rounded-xl bg-white p-3 shadow-lg dark:bg-zinc-900">
<p className="text-[12px] font-semibold">Invite to Acme</p>
<div className="mt-2 flex flex-wrap gap-1 rounded-md border border-zinc-200 p-1.5 dark:border-zinc-700">
{["rin@acme.co", "luca@acme.co", "ana@acme.co"].map((email) => (
<span key={email} className={cn("rounded px-1.5 py-0.5", accent.badge)}>
{email}
</span>
))}
</div>
<div className="mt-2 flex gap-1.5">
<span className="flex-1 rounded-md border border-zinc-200 px-2 py-1.5 text-zinc-500 dark:border-zinc-700">
Member ▾
</span>
<span className={cn("rounded-md px-2 py-1.5 font-medium text-white", accent.solid)}>Send</span>
</div>
</div>
</div>
);
}
function AuditScreen() {
const entries = [
["09:41", "Maya changed Rin's role to Admin"],
["09:12", "Jonas exported Insights as PDF"],
["08:57", "Luca joined the workspace"],
["08:30", "Maya turned on SSO"],
];
return (
<Window title="Audit log">
<div className="divide-y divide-zinc-100 dark:divide-zinc-800">
{entries.map(([time, text]) => (
<div key={time} className="flex gap-3 py-1.5">
<span className="font-mono text-zinc-400 tabular-nums">{time}</span>
<span className="truncate">{text}</span>
</div>
))}
</div>
</Window>
);
}
Travel Journal
A year of trips, one group each, in portrait and landscape. Each photo has its own caption, and the trip's dates and a day count sit above.
Lisbon & Porto
Mar 3 – 11
Day 1Tram 28 through Baixa
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { PlayheadReel, type PlayheadReelItem } from "@/components/ui/playhead-reel";
type Photo = PlayheadReelItem & { day: number };
type Trip = { label: string; place: string; dates: string; items: Photo[] };
const photo = (id: string, aspect: number, day: number, alt: string): Photo => ({
src: `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=${Math.round(560 * aspect)}&h=560&q=65`,
aspect,
day,
alt,
});
const trips: Trip[] = [
{
label: "Portugal",
place: "Lisbon & Porto",
dates: "Mar 3 – 11",
items: [
photo("1585208798174-6cedd86e019a", 3 / 4, 1, "Tram 28 through Baixa"),
photo("1548707309-dcebeab9ea9b", 4 / 3, 1, "Under the Rua Augusta arch"),
photo("1536663815808-535e2280d2c2", 4 / 3, 2, "Alfama rooftops"),
photo("1525207934214-58e69a8f8a3e", 3 / 2, 3, "The city after dark"),
photo("1555881400-74d7acaacd8b", 4 / 3, 6, "Ribeira, Porto"),
photo("1513735492246-483525079686", 3 / 2, 7, "The bridge at sunset"),
],
},
{
label: "Japan",
place: "Kyoto & Tokyo",
dates: "Apr 2 – 14",
items: [
photo("1478436127897-769e1b3f0f36", 3 / 4, 2, "Fushimi Inari before the crowds"),
photo("1493976040374-85c8e12f0c0e", 3 / 4, 3, "Higashiyama, early"),
photo("1545569341-9eb8b30979d9", 3 / 2, 3, "Kyoto at dusk"),
photo("1490806843957-31f4c9a91c65", 4 / 3, 7, "Fuji through the blossoms"),
photo("1524413840807-0c3cb6fa808d", 4 / 3, 9, "Meguro River in bloom"),
photo("1540959733332-eab4deabeeaf", 4 / 3, 10, "Tokyo, late"),
photo("1503899036084-c55cdd92da26", 3 / 4, 11, "Signs on every floor"),
],
},
{
label: "Dolomites",
place: "The Dolomites",
dates: "Jul 8 – 15",
items: [
photo("1501785888041-af3ef285b470", 4 / 3, 1, "Lago di Braies"),
photo("1506905925346-21bda4d32df4", 3 / 2, 3, "Above the clouds at sunrise"),
photo("1464822759023-fed622ff2c3b", 4 / 3, 4, "Down the valley"),
photo("1520769945061-0a448c463865", 4 / 3, 5, "Lunch by the lake"),
photo("1486870591958-9b9d0d1dda99", 3 / 4, 6, "Snow in July"),
],
},
{
label: "Iceland",
place: "Iceland",
dates: "Sep 19 – 27",
items: [
photo("1474690870753-1b92efa1f2d8", 3 / 4, 1, "Hallgrímskirkja, Reykjavík"),
photo("1476610182048-b716b8518aae", 4 / 3, 2, "Seljalandsfoss"),
photo("1500043357865-c6b8827edf10", 3 / 2, 3, "Black sand at Reynisfjara"),
photo("1504893524553-b855bce32c67", 3 / 4, 4, "Fjaðrárgljúfur canyon"),
photo("1529963183134-61a90db47eaf", 4 / 3, 5, "Aurora over the lagoon"),
photo("1531366936337-7c912a4589a7", 4 / 3, 7, "Northern lights, again"),
],
},
{
label: "Big Sur",
place: "Big Sur",
dates: "Nov 6 – 10",
items: [
photo("1510414842594-a61c69b5ae57", 4 / 3, 1, "McWay Falls"),
photo("1505142468610-359e7d316be0", 3 / 4, 2, "Where the road meets the sea"),
photo("1502680390469-be75c86b636f", 4 / 3, 3, "Morning swell"),
photo("1473116763249-2faaef81ccda", 3 / 2, 4, "Last light on the coast"),
],
},
];
export function PlayheadReelJournalDemo() {
const scrollRef = React.useRef<HTMLDivElement>(null);
const [view, setView] = React.useState({ group: 0, item: 0, direction: 1 });
const trip = trips[view.group];
const shot = trip.items[view.item];
return (
<div className="relative w-full overflow-hidden rounded-xl border border-stone-200 bg-stone-100 text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-100">
<div ref={scrollRef} className="relative h-[var(--demo-height,38rem)] overflow-y-auto overscroll-contain">
<PlayheadReel
groups={trips}
containerRef={scrollRef}
stageClassName="h-[var(--demo-height,38rem)]"
itemClassName="rounded-[2px] ring-[5px] ring-white dark:ring-stone-800"
reach={2}
onActiveChange={({ group, item }) =>
setView((prev) => ({
group,
item,
direction: group * 100 + item >= prev.group * 100 + prev.item ? 1 : -1,
}))
}
header={
<header className="px-4 pt-5 pb-4 sm:px-8 sm:pt-7">
<div className="flex items-center justify-between gap-4 font-mono text-[11px] tracking-widest text-stone-500 uppercase tabular-nums">
<span>Journal · 2025</span>
<span>
Trip {view.group + 1} of {trips.length}
</span>
</div>
<div className="mt-2 flex items-end justify-between gap-4">
<Roll id={view.group} direction={view.direction} className="min-w-0">
<h2 className="truncate font-serif text-3xl leading-[1.15] italic sm:text-5xl">{trip.place}</h2>
</Roll>
<Roll id={view.group} direction={view.direction} className="shrink-0">
<p className="pb-1 font-mono text-xs text-stone-500 tabular-nums sm:text-sm">{trip.dates}</p>
</Roll>
</div>
<Roll id={view.group * 100 + view.item} direction={view.direction} className="mt-1">
<p className="truncate text-sm leading-6 text-stone-500">
<span className="font-mono text-xs text-stone-400 tabular-nums">Day {shot?.day}</span>
<span aria-hidden className="px-2 text-stone-300 dark:text-stone-700">
/
</span>
{shot?.alt}
</p>
</Roll>
</header>
}
/>
</div>
</div>
);
}
/** Rolls its content out and the next one in, up when moving forward and down when going back. */
function Roll({
id,
direction,
className,
children,
}: {
id: number;
direction: number;
className?: string;
children: React.ReactNode;
}) {
const reduceMotion = useReducedMotion();
return (
<div className={cn("relative overflow-hidden", className)}>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={id}
custom={direction}
variants={{
enter: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * 100}%` }),
center: { y: 0, opacity: 1 },
exit: (d: number) => (reduceMotion ? { opacity: 0 } : { y: `${d * -100}%` }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: reduceMotion ? 0.2 : 0.5, ease: [0.23, 1, 0.32, 1] }}
>
{children}
</motion.div>
</AnimatePresence>
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
groupsRequired | PlayheadReelGroup[] | None | What's on the reel, in order. Each group has a label for the ruler ("v2.4", "2019", "Lisbon") and its items. Groups sit apart in the strip, and the playhead pauses briefly in each gap. |
src | string | None | PlayheadReelItem: image URL. Images are fetched once the reel comes within a screen of the viewport. |
alt | string | None | PlayheadReelItem: alt text. The playhead handle also reads it out as it reaches the item. |
aspect | number | 4 / 3 | PlayheadReelItem: width ÷ height. Sets how wide the item is in the strip and the shape it opens to. Mixing shapes gives the strip its rhythm. |
content | ReactNode | None | PlayheadReelItem: your own markup instead of an image, laid out at the item's open size and scaled down with it. Use it for UI mockups, cards or video. |
thumbHeight | number | 40 | Height of an item in the strip, in px. |
scrollPerItem | number | 80 | Pixels of scroll it takes to move the playhead one item along. |
reach | number | 2.5 | Roughly how many items on each side of the playhead open up. Lower keeps the collage tight; higher opens more of the strip at once. |
skim | boolean | true | Fold the collage flat into the strip while scrolling fast, so you can see where you're going, and open it again when you slow down. Off with reduced motion. |
header | ReactNode | None | Pinned above the reel, in the flow, so the collage never covers it. Put titles, counters and links here, driven by onActiveChange. |
footer | ReactNode | None | Pinned below the ruler, in the flow. |
onActiveChange | (active: { group: number; item: number }) => void | None | Called with the group and item under the playhead whenever they change, read from the smoothed position so it matches what's on screen. item counts from 0 within its group. |
containerRef | RefObject<HTMLElement | null> | None | Scroll container to track. Defaults to the window. The container must be positioned (e.g. relative). |
stageClassName | string | "h-dvh" | Classes for the pinned stage. Set its height and text color here: the playhead, ruler and labels are drawn in the text color. |
itemClassName | string | None | Classes for every item's frame, which defaults to rounded-lg with a soft shadow. Hovering an open item lifts it to the top of the pile. |
className | string | None | Extra classes for the outer section. Other section props pass through. |
groupsRequiredType
PlayheadReelGroup[]What's on the reel, in order. Each group has a
labelfor the ruler ("v2.4", "2019", "Lisbon") and itsitems. Groups sit apart in the strip, and the playhead pauses briefly in each gap.srcType
stringPlayheadReelItem: image URL. Images are fetched once the reel comes within a screen of the viewport.altType
stringPlayheadReelItem: alt text. The playhead handle also reads it out as it reaches the item.aspectType
numberDefault4 / 3PlayheadReelItem: width ÷ height. Sets how wide the item is in the strip and the shape it opens to. Mixing shapes gives the strip its rhythm.contentType
ReactNodePlayheadReelItem: your own markup instead of an image, laid out at the item's open size and scaled down with it. Use it for UI mockups, cards or video.thumbHeightType
numberDefault40Height of an item in the strip, in px.
scrollPerItemType
numberDefault80Pixels of scroll it takes to move the playhead one item along.
reachType
numberDefault2.5Roughly how many items on each side of the playhead open up. Lower keeps the collage tight; higher opens more of the strip at once.
skimType
booleanDefaulttrueFold the collage flat into the strip while scrolling fast, so you can see where you're going, and open it again when you slow down. Off with reduced motion.
headerType
ReactNodePinned above the reel, in the flow, so the collage never covers it. Put titles, counters and links here, driven by
onActiveChange.footerType
ReactNodePinned below the ruler, in the flow.
onActiveChangeType
(active: { group: number; item: number }) => voidCalled with the group and item under the playhead whenever they change, read from the smoothed position so it matches what's on screen.
itemcounts from 0 within its group.containerRefType
RefObject<HTMLElement | null>Scroll container to track. Defaults to the window. The container must be positioned (e.g.
relative).stageClassNameType
stringDefault"h-dvh"Classes for the pinned stage. Set its height and text color here: the playhead, ruler and labels are drawn in the text color.
itemClassNameType
stringClasses for every item's frame, which defaults to
rounded-lgwith a soft shadow. Hovering an open item lifts it to the top of the pile.classNameType
stringExtra classes for the outer section. Other
sectionprops pass through.