Display

Sticky Stack

Multi-level sticky section headers that park and shove one another, with every parking line accumulated from the measured heights of the headers above it.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type StickyStackMode = "push" | "stack"

interface StickyStackContextValue {
  mode: StickyStackMode
  /** Parking line (px below the scrollport top) per registered header element. */
  tops: ReadonlyMap<HTMLElement, number>
  /** Headers whose box currently covers their parking line. */
  stuck: ReadonlySet<HTMLElement>
  /** Line used by a header that has not been measured yet. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sticky-stack.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "StickyStack" component: several section
headers that stick and coordinate with each other (no animation library, no
scroll library — position: sticky plus measurement).

Contract
- Export two forwardRef components sharing one context:
  - StickyStack (renders a div, extends React.HTMLAttributes<HTMLDivElement>):
      mode?: "push" | "stack"   default "push"
      offset?: number           default 0 — px between the scrollport top and the
                                first parking line (a page header above the stack).
                                Negative / NaN / Infinity clamp to 0.
      onStuckChange?: (stuckIndexes: number[]) => void — DOM-order indexes of every
                                currently parked section; fires only when that set
                                changes, never twice with the same value.
  - StickyStackSection (renders a div, extends React.HTMLAttributes<HTMLDivElement>):
      header: ReactNode         the node that parks at the top
      headerClassName?: string  classes for the sticky header wrapper
      children: ReactNode       the section body
- The stack root publishes --sticky-stack-cover (the tallest header pile an anchor
  inside can end up under); each header publishes --sticky-stack-top (its own line)
  and data-stuck="true" | "false". Both are real string attributes/properties so
  consumers can style off them in plain CSS.

Behavior
- Sections register their header node with the stack through context; the stack
  keeps them in DOM order (compareDocumentPosition), not mount order, so
  conditional or reordered sections stay correct.
- ONE measurement pass computes everything from one batch of getBoundingClientRect
  reads:
    line = offset                       for mode="push" (every header shares it)
    line = offset + sum(measured heights of the headers above it)  for mode="stack"
  Heights are always measured (fractional rect height, not offsetHeight — integer
  rounding drifts ~0.4px per header and opens visible seams in the pile). Never
  hardcode a header height: wrapping, type scale and browser zoom all change it.
- The same pass decides stuck: with `absolute = scrollportTop + line`, a header is
  stuck when rect.top <= absolute + 0.5 AND rect.bottom > absolute + 0.5 — parked on
  the line (or already sliding above it) and still covering it. Dropping the second
  clause reports headers that were shoved off-screen as still stuck.
- scrollportTop is found by walking up from the root to the nearest ancestor whose
  computed overflow-y is neither "visible" nor "clip" (that box is the scrollport;
  use its padding-box top), else 0 for the viewport. Do not assume the page scrolls.
- The pass is driven by three inputs, all rAF-throttled through one shared frame id:
  a scroll listener on window in the CAPTURE phase (scroll events do not bubble, but
  they do propagate down — one listener therefore covers the page and any nested
  scroll container), a window resize listener, and a ResizeObserver watching every
  header (heights) plus the root (content above a header moving it with no scroll
  event). Cancel the frame, disconnect the observer and remove both listeners on
  unmount and whenever mode/offset/the header set changes.
- Structure decides the two modes, and it is not cosmetic:
  - push: the section renders a wrapper div containing [header, children]. The
    header can never leave its own section box, so the next section's top edge
    shoves it out — the iOS grouped-table handoff, and exactly one header covers
    the line at any moment.
  - stack: the header is hoisted OUT of the section wrapper (rendered as a sibling
    via a fragment) so every header shares the stack root as its containing block.
    That is the precondition for piling: leave the headers inside their section
    boxes and the previous one slides away as the next arrives, leaving a gap where
    it used to be. Consequence to document: in stack mode className/id/ref on the
    section describe the content box, not the header.
- Run the first pass in a layout effect (isomorphic, so SSR does not warn) so the
  lines are right before the first paint.
- Dev-only: if the nearest scroll container above the headers is one the user cannot
  scroll (overflow hidden/clip, or an overflow-auto box with nothing to scroll),
  console.warn once with that element — sticky anchors to it and the headers will
  never park. This is the single most common way sticky silently does nothing, and
  note that overflow-x: hidden alone computes overflow-y to auto and causes it.

Rendering & styling
- Semantic tokens only. The header wrapper defaults to `sticky z-10 bg-background`
  (opaque, or content scrolls through it) and merges headerClassName via cn(); the
  root adds nothing but the custom property. No colors, radii or spacing of its own —
  every visual belongs to the consumer's header and children.
- No animation at all, so nothing to gate behind prefers-reduced-motion; any motion
  a consumer hangs off data-stuck (shadow, shrink) should carry motion-reduce:transition-none.
- Accessibility: the wrappers are presentational. Pass a real heading as `header`
  and, if the section is a landmark, spread role/aria-labelledby onto the section.
- Anchor targets inside a stack get covered by the pile: consumers should write
  scroll-margin-top: var(--sticky-stack-cover) on them (verified: with it the target
  lands exactly at the pile bottom, without it 0.2px below the scrollport top, i.e.
  fully hidden behind a 247px pile).

Customization levers
- Mode: "push" for long grouped lists where only the current group matters;
  "stack" for a handful of sections whose context should stay on screen. Stack with
  many sections eats the viewport — the pile is the sum of every header height.
- offset: park the whole stack below a fixed site header or a toolbar inside the
  scroll container; it also shifts --sticky-stack-cover, so scroll-margin-top follows.
- headerClassName is the whole styling surface: bg-background/80 + backdrop-blur for
  glass, border-b for a seam, data-[stuck=true]:shadow-sm for a lift only while
  parked, data-[stuck=true]:py-1 for a header that tightens when it parks.
- onStuckChange drives whatever CSS cannot: highlighting the current group in a
  sidebar, a breadcrumb that follows the top-most section, analytics on reading depth.
- z-index: headers default to z-10 within the stack; raise it if the consumer's
  content creates its own stacking contexts.

Concepts

  • Measured accumulation, never a constant — each parking line is offset + the summed rect heights of the headers above it, recomputed by a ResizeObserver whenever any header changes size. A hardcoded step (index * 32) looks fine on the design mock and breaks on the first wrapped title, larger type scale or browser zoom; measured fractional heights also matter, since offsetHeight rounding drifts about 0.4px per header and opens seams in the pile.
  • Push and stack are a containing-block decision — in push, the header stays inside its section box, so the section's own bottom edge shoves it out as the next section arrives (exactly one header covers the line at any moment). In stack, the header is hoisted out of the section box so that every header shares the stack root as its containing block; leave them inside their sections and the earlier header slides away as the next one parks, leaving a gap exactly where it used to be. The cost of hoisting: in stack mode className/id/ref on a section describe its content box, not its header.
  • Stuck is published, not decorated — the component adds no shadow, no shrink, no blur. It writes data-stuck="true" | "false" on each header (style it in plain CSS: data-[stuck=true]:shadow-sm) and calls onStuckChange with the DOM-order indexes of every parked section, only when that set actually changes — for the things CSS cannot do, like highlighting the current group in a sidebar.
  • The overflow trap, and how to find itposition: sticky sticks to the nearest ancestor scroll container, so any ancestor with overflow: hidden (or an overflow-auto box with nothing to scroll, or overflow: clip, which clips it instead) silently turns a sticky header into an ordinary one. Note that overflow-x: hidden alone computes overflow-y to auto and does this too. To find it: select the header in DevTools and walk up the ancestors reading computed overflow-x/overflow-y; the first box that is not visible is your scrollport — if that box is not the thing the user scrolls, that is the bug. This component checks that chain in development and warns once, naming the offending element.
  • Anchors need scroll-margin-top — jumping to an id inside the stack lands the target under the header pile. The root publishes --sticky-stack-cover (the tallest pile an anchor can end up under, offset included), so [id] { scroll-margin-top: var(--sticky-stack-cover); } is the whole fix; it follows the measured heights and the offset prop automatically.
  • One pass, one frame, full cleanup — scroll (captured at window, which is what makes it work inside nested scroll containers, since scroll events do not bubble), resize, and ResizeObserver all funnel into a single requestAnimationFrame-throttled measurement; on unmount the frame is cancelled, the observer disconnected and both listeners removed.

On This Page