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.

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