Work index

A project index of thumbnail strips: rest on a row and every other row recedes while the heading cross-fades to that project's title.

Show code
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useScrollFade, SCROLL_FADE_CLASS } from "@/hooks/use-scroll-fade";

/** Pointer must rest on a row this long before the dim and title swap fire. */
const HOVER_HOLD_MS = 450;
const HEAVY = "cubic-bezier(0.76, 0, 0.24, 1)";

export type WorkIndexThumb = { key: string; aspect: number };

export type WorkIndexItem = {
  id: string;
  /** Accessible name of the row, and the heading while the row has focus. */
  title: string;
  thumbs: WorkIndexThumb[];
};

export type WorkIndexProps<T extends WorkIndexItem> = {
  items: T[];
  /** Standing headline; it swaps to the focused row's title. */
  heading?: string;
  renderThumb: (thumb: WorkIndexThumb, item: T) => ReactNode;
  /** Caption under each strip (client, title, category…). */
  renderCaption?: (item: T) => ReactNode;
  /** What the heading shows for a focused row. Defaults to item.title. */
  renderTitle?: (item: T) => ReactNode;
  /** Makes rows links. */
  href?: (item: T) => string;
  /** Start with this row focused (static previews); the pointer takes over after. */
  initialFocusedId?: string;
  className?: string;
};

/** Drop transient hover once the page scrolls: the pointer has left it behind. */
function useDismissOnScroll(dismiss: () => void) {
  const dismissRef = useRef(dismiss);
  useEffect(() => {
    dismissRef.current = dismiss;
  });
  useEffect(() => {
    let frame = 0;
    const onScroll = () => {
      if (frame) return;
      frame = window.requestAnimationFrame(() => {
        frame = 0;
        dismissRef.current();
      });
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", onScroll);
      if (frame) window.cancelAnimationFrame(frame);
    };
  }, []);
}

function Strip<T extends WorkIndexItem>({
  item,
  renderThumb,
}: {
  item: T;
  renderThumb: WorkIndexProps<T>["renderThumb"];
}) {
  const ref = useScrollFade<HTMLDivElement>();
  return (
    <div
      ref={ref}
      className={cn(
        "-mx-3 h-[var(--work-thumb-height)] overflow-x-auto overflow-y-clip overscroll-x-contain [scrollbar-width:none]",
        SCROLL_FADE_CLASS,
      )}
    >
      <div className="flex h-full w-max gap-1 px-3">
        {item.thumbs.map((thumb) => (
          <div key={thumb.key} className="relative h-full shrink-0" style={{ aspectRatio: thumb.aspect }}>
            {renderThumb(thumb, item)}
          </div>
        ))}
      </div>
    </div>
  );
}

/**
 * A project index of thumbnail strips. Resting on a row (450ms) or focusing it
 * recedes every other row to 18% and cross-fades the page heading to that
 * row's title through a 4px blur, so the pointed-at project reads on its own.
 */
export function WorkIndex<T extends WorkIndexItem>({
  items,
  heading = "Work",
  renderThumb,
  renderCaption,
  renderTitle,
  href,
  initialFocusedId,
  className,
}: WorkIndexProps<T>) {
  const [hoveredId, setHoveredId] = useState<string | null>(initialFocusedId ?? null);
  const hoverTimer = useRef<number | null>(null);

  const clearHoverTimer = () => {
    if (hoverTimer.current !== null) {
      window.clearTimeout(hoverTimer.current);
      hoverTimer.current = null;
    }
  };

  // Keyboard focus commits at once; the pointer has to rest first.
  const commitHover = (id: string | null) => {
    clearHoverTimer();
    setHoveredId(id);
  };

  const requestHover = (id: string | null) => {
    clearHoverTimer();
    if (id === null) {
      setHoveredId(null);
      return;
    }
    hoverTimer.current = window.setTimeout(() => {
      setHoveredId(id);
      hoverTimer.current = null;
    }, HOVER_HOLD_MS);
  };

  useDismissOnScroll(() => commitHover(null));
  useEffect(() => clearHoverTimer, []);

  const hovered = items.find((item) => item.id === hoveredId) ?? null;
  // Held through the fade-out so the heading never blanks mid-transition.
  const [lastHovered, setLastHovered] = useState<T | null>(null);
  if (hovered && hovered !== lastHovered) setLastHovered(hovered);

  const lineClass = cn(
    "col-start-1 row-start-1 m-0 truncate text-[20px] font-semibold leading-6 tracking-[-0.025em] text-foreground",
    "transition-[opacity,filter] duration-[900ms] motion-reduce:transition-none",
    "data-[on=false]:pointer-events-none data-[on=false]:opacity-0 data-[on=false]:blur-[4px]",
  );

  return (
    <div className={cn("@container [--work-thumb-height:clamp(120px,22cqw,160px)]", className)}>
      <header className="grid pb-3">
        <h2 className={lineClass} data-on={!hovered} style={{ transitionTimingFunction: HEAVY }}>
          {heading}
        </h2>
        <span aria-hidden className={lineClass} data-on={Boolean(hovered)} style={{ transitionTimingFunction: HEAVY }}>
          {lastHovered ? (renderTitle ? renderTitle(lastHovered) : lastHovered.title) : null}
        </span>
      </header>

      <ol className="m-0 flex list-none flex-col gap-12 p-0" data-hovering={Boolean(hovered)}>
        {items.map((item) => {
          const Row = href ? "a" : "div";
          return (
            <li
              key={item.id}
              data-hovered={item.id === hoveredId}
              className="group/row min-w-0"
              onPointerEnter={(event) => {
                if (event.pointerType !== "touch") requestHover(item.id);
              }}
              onPointerLeave={() => requestHover(null)}
            >
              <Row
                {...(href ? { href: href(item) } : { tabIndex: 0, role: "group" })}
                aria-label={item.title}
                onFocus={() => commitHover(item.id)}
                onBlur={() => commitHover(null)}
                className={cn(
                  "block min-w-0 rounded-sm text-inherit no-underline outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  "transition-opacity duration-[900ms] motion-reduce:transition-none",
                  hovered && item.id !== hoveredId && "opacity-[0.18]",
                )}
                style={{ transitionTimingFunction: HEAVY }}
              >
                <Strip item={item} renderThumb={renderThumb} />
                {renderCaption ? <div className="pt-1">{renderCaption(item)}</div> : null}
              </Row>
            </li>
          );
        })}
      </ol>
    </div>
  );
}