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.

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