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

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/playhead-reel
- Tags: Gallery, Scroll, Timeline
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Maria Vasilyeva](https://www.mariavasilyeva.com/)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/playhead-reel.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/playhead-reel
```

Or install by URL, without registering the namespace:

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

## Usage

The main demo shows typical usage.

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

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/playhead-reel-demo
```

```tsx title="components/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>
  );
}
```

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

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/playhead-reel-changelog-demo
```

```tsx title="components/playhead-reel-changelog-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";

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.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/playhead-reel-journal-demo
```

```tsx title="components/playhead-reel-journal-demo.tsx"
"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 |
| --- | --- | --- | --- |
| `groups` (required) | `PlayheadReelGroup[]` |  | 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` |  | `PlayheadReelItem`: image URL. Images are fetched once the reel comes within a screen of the viewport. |
| `alt` | `string` |  | `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` |  | `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` |  | Pinned above the reel, in the flow, so the collage never covers it. Put titles, counters and links here, driven by `onActiveChange`. |
| `footer` | `ReactNode` |  | Pinned below the ruler, in the flow. |
| `onActiveChange` | `(active: { group: number; item: number }) => void` |  | 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>` |  | 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` |  | 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` |  | Extra classes for the outer section. Other `section` props pass through. |

## Source

```tsx title="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);
}
```
