# Playhead Reel: Default

> 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.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/playhead-reel-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/playhead-reel-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/playhead-reel-demo-light-2582a1177004adde.png
- Registry item: https://keyframeui.com/r/playhead-reel-demo.json
- Categories: gallery, scroll, timeline
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/playhead-reel-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/playhead-reel.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/playhead-reel#playhead-reel-demo

## Files

### `registry/demos/playhead-reel-demo.tsx`

```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>
  );
}
```
