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.
@keyframeui
registry/demos/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>
);
}
Live preview, examples and props: https://keyframeui.com/components/playhead-reel#playhead-reel-journal-demo






















