Stacking index
A photo feed where each project's one-line header sticks 16px below the last, so scrolling piles them into a running index, blended to read over any photo.
Loading preview…
Show code
"use client";
import { Fragment, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export type StackingIndexItem = {
id: string;
/** Short running number, e.g. "001". */
index: ReactNode;
title: ReactNode;
/**
* The remaining columns (on the site: author, category, camera). Wide
* containers show all three; below 992px only the first stays.
*/
meta: [ReactNode, ReactNode?, ReactNode?];
};
export type StackingIndexProps<T extends StackingIndexItem> = {
items: T[];
/** The project's photograph (or any media) under its header. */
renderMedia: (item: T, index: number) => ReactNode;
/** Makes each header row and photograph a link. */
href?: (item: T) => string;
/** Top-right control on the first header, e.g. a link to the archive. */
action?: ReactNode;
/** Sticky top of the first header, px. */
top?: number;
/** How far each later header sticks below the one before, px. */
step?: number;
/**
* Empty space after the last photo, px, inside the same column so the last
* headers can finish stacking (the site uses one screen height).
*/
trailing?: number;
className?: string;
};
const cell = "transition-opacity group-hover:opacity-50 group-focus-visible:opacity-50 motion-reduce:transition-none";
/**
* A feed of photographs, each under a one-line metadata header. Headers are
* sticky at `top + i × step`, and every header and photo is a direct sibling
* in one column, so a header never unsticks when its photo scrolls away: they
* pile up into a running index of everything above. Headers blend with
* exclusion (white text), so the pile reads over light and dark photos alike.
*/
export function StackingIndex<T extends StackingIndexItem>({
items,
renderMedia,
href,
action,
top = 12,
step = 16,
trailing = 0,
className,
}: StackingIndexProps<T>) {
return (
<div className={cn("@container isolate flex flex-col bg-background", className)}>
{items.map((item, i) => {
const link = href?.(item);
const Row = link ? "a" : "div";
return (
<Fragment key={item.id}>
<header
className="sticky z-30 w-full text-white mix-blend-exclusion"
style={{ top: top + i * step }}
>
<Row
{...(link ? { href: link } : {})}
className="group grid grid-cols-5 outline-none @max-[992px]:grid-cols-2"
>
<span className="flex items-center @max-[992px]:gap-4">
<span className={cell}>{item.index}</span>
<span className={cn(cell, "hidden min-w-0 flex-1 font-medium @max-[992px]:block")}>{item.title}</span>
</span>
<span className={cn(cell, "font-medium @max-[992px]:hidden")}>{item.title}</span>
<span className={cn(cell, "font-medium")}>{item.meta[0]}</span>
<span className={cn(cell, "font-medium @max-[992px]:hidden")}>{item.meta[1]}</span>
<span className={cn(cell, "font-medium @max-[992px]:hidden")}>{item.meta[2]}</span>
</Row>
{i === 0 && action ? <div className="absolute top-0 right-0">{action}</div> : null}
</header>
<section
className={cn(
"relative z-0 flex items-center justify-center @max-[992px]:mb-[140px] @max-[992px]:px-0 @max-[992px]:py-4",
// The first photo sits 108px under the header and 250px above
// the next one; later photos keep 250px between them. Page
// margins are the consumer's.
i === 0 ? "pt-[108px] pb-[250px]" : "mb-[250px] p-[5px]",
)}
>
{/* data-view-cursor: a ViewCursor around the feed turns the pointer into "View" over photos. */}
<figure data-view-cursor="" className="relative z-0 m-0 w-full max-w-[1200px] px-4 @max-[992px]:px-0">
{link ? (
// The header row is the accessible link; this duplicate is for the pointer only.
<a href={link} tabIndex={-1} aria-hidden className="block">
{renderMedia(item, i)}
</a>
) : (
renderMedia(item, i)
)}
</figure>
</section>
</Fragment>
);
})}
{trailing ? <div aria-hidden style={{ height: trailing }} /> : null}
</div>
);
}