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.
Loading preview…
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>
);
}