# Playhead Reel: 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.

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

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/playhead-reel-journal-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/playhead-reel-journal-demo-light-9018d58bdfbc557d.png
- Registry item: https://keyframeui.com/r/playhead-reel-journal-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-journal-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-journal-demo

## Files

### `registry/demos/playhead-reel-journal-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 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>
  );
}
```
