A studio's archive: six projects, forty images. Scroll and the playhead walks the reel while each project opens into a collage under it, and the title rolls to the next as you cross into it. Scroll fast and the collage folds flat to let you skim. Hover a photo to lift it off the pile, click one to bring it under the playhead, or drag along the ruler.
@keyframeui
registry/demos/playhead-reel-demo.tsx
"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>
);
}
Live preview, examples and props: https://keyframeui.com/components/playhead-reel#playhead-reel-demo























