Filmstrip hero
A full-bleed hero driven by one row of thumbnails: as many as fit the width at their own ratios, never scrolling, with hover crossfading the photo behind.
Loading preview…
Show code
"use client";
import { useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent } from "react";
import { cn } from "@/lib/utils";
export type FilmstripItem = {
/** Intrinsic size; thumb width is row height × ratio. */
width: number;
height: number;
/** Omit to draw a grey stand-in at the item's ratio. */
src?: string;
title?: string;
};
export type FilmstripHeroProps = {
items: FilmstripItem[];
/** Called with the item index and the click, on frames 768px and wider. */
onOpen?: (index: number, event: MouseEvent) => void;
/** Item shown before the first hover. */
defaultIndex?: number;
label?: string;
className?: string;
};
// Row height and how many items the strip may draw from, by frame width
// (the original's 768 / 1024 breakpoints, read from the frame, not the window).
function layoutFor(width: number) {
if (width < 768) return { rowHeight: 48, max: 12, compact: true };
if (width < 1024) return { rowHeight: 64, max: 12, compact: false };
return { rowHeight: 72, max: 24, compact: false };
}
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Greedily take items until the next one would overflow, so the strip spans
* the frame at one row height and never scrolls. Always keeps at least one.
*/
export function fitToWidth<T extends { width: number; height: number }>(items: T[], rowHeight: number, width: number) {
if (!width) return items;
const fitted: T[] = [];
let used = 0;
for (const item of items) {
const w = rowHeight * (item.width && item.height ? item.width / item.height : 1);
if (used + w > width) break;
used += w;
fitted.push(item);
}
return fitted.length > 0 ? fitted : items.slice(0, 1);
}
function Media({ item, className, style }: { item: FilmstripItem; className?: string; style?: CSSProperties }) {
const ratio = item.width / item.height;
if (item.src) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={item.src} alt="" loading="eager" decoding="async" className={cn("object-contain", className)} style={style} />;
}
return (
<span aria-hidden className={cn("flex items-center justify-center [container-type:size]", className)} style={style}>
<span className="block bg-muted" style={{ aspectRatio: ratio, width: `min(100cqw, ${100 * ratio}cqh)` }} />
</span>
);
}
export function FilmstripHero({
items,
onOpen,
defaultIndex = 0,
label = "Featured photos",
className,
}: FilmstripHeroProps) {
const rootRef = useRef<HTMLDivElement>(null);
const [frameWidth, setFrameWidth] = useState(0);
const [active, setActive] = useState(defaultIndex);
// Size from the frame itself, so the strip works in any box, not just a
// full-viewport page. offsetWidth ignores CSS transforms (scaled previews).
useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setFrameWidth(el.offsetWidth));
ro.observe(el);
setFrameWidth(el.offsetWidth);
return () => ro.disconnect();
}, []);
const { rowHeight, max, compact } = layoutFor(frameWidth || 1024);
const thumbs = useMemo(() => fitToWidth(items.slice(0, max), rowHeight, frameWidth), [items, max, rowHeight, frameWidth]);
const activeIndex = active < thumbs.length ? active : 0;
const current = thumbs[activeIndex];
const canOpen = Boolean(onOpen) && !compact;
const nameOf = (item: FilmstripItem | undefined, i: number) => item?.title || `Photo ${i + 1} of ${thumbs.length}`;
return (
<div ref={rootRef} className={cn("relative h-full w-full overflow-hidden", className)}>
{/* Every image stays mounted and stacked; hover only changes which one
is opaque, so a swap is a 600ms crossfade with nothing to load. */}
<div
className={cn("absolute inset-0", canOpen && "cursor-pointer")}
onClick={canOpen ? (e) => onOpen?.(activeIndex, e) : undefined}
aria-hidden
>
{thumbs.map((item, i) => (
<Media
key={i}
item={item}
className="pointer-events-none absolute inset-0 h-full w-full transition-opacity motion-reduce:transition-none"
style={{ opacity: i === activeIndex ? 1 : 0, transitionDuration: "600ms", transitionTimingFunction: EASE }}
/>
))}
</div>
<div
role="group"
aria-label={label}
className="absolute inset-x-0 top-1/2 z-10 flex -translate-y-1/2 items-center justify-between overflow-hidden"
>
{thumbs.map((item, i) => {
const isActive = i === activeIndex;
return (
<button
key={i}
type="button"
aria-label={canOpen ? `Open ${nameOf(item, i)}` : nameOf(item, i)}
onPointerEnter={() => setActive(i)}
onFocus={() => setActive(i)}
onClick={(e) => {
setActive(i);
if (canOpen) onOpen?.(i, e);
}}
className={cn(
"block shrink-0 outline-none transition-[opacity,filter] focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
canOpen && "cursor-pointer",
)}
style={{
height: rowHeight,
opacity: isActive ? 1 : 0.2,
filter: isActive ? "grayscale(0%)" : "grayscale(100%)",
transitionDuration: "400ms",
transitionTimingFunction: EASE,
}}
>
{item.src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.src} alt="" decoding="async" className="block h-full w-auto" />
) : (
<span
aria-hidden
className="block h-full bg-muted-foreground/60"
style={{ aspectRatio: item.width / item.height }}
/>
)}
</button>
);
})}
</div>
<span className="sr-only" aria-live="polite">
{nameOf(current, activeIndex)}
</span>
</div>
);
}