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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sticky-stack.jsonPrompt
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 aResizeObserverwhenever 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, sinceoffsetHeightrounding 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). Instack, 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 modeclassName/id/refon 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 callsonStuckChangewith 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 it —
position: stickysticks to the nearest ancestor scroll container, so any ancestor withoverflow: hidden(or anoverflow-autobox with nothing to scroll, oroverflow: clip, which clips it instead) silently turns a sticky header into an ordinary one. Note thatoverflow-x: hiddenalone computesoverflow-ytoautoand does this too. To find it: select the header in DevTools and walk up the ancestors reading computedoverflow-x/overflow-y; the first box that is notvisibleis 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 anidinside the stack lands the target under the header pile. The root publishes--sticky-stack-cover(the tallest pile an anchor can end up under,offsetincluded), so[id] { scroll-margin-top: var(--sticky-stack-cover); }is the whole fix; it follows the measured heights and theoffsetprop 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, andResizeObserverall funnel into a singlerequestAnimationFrame-throttled measurement; on unmount the frame is cancelled, the observer disconnected and both listeners removed.
Virtual Grid
Two-dimensional windowing — only the cells near the viewport exist in the DOM, with fixed or per-track measured row heights and column widths, and no virtualization library.
Safe Area
A wrapper that pads or offsets content by the OS safe-area insets — notch, rounded corners, home indicator, landscape side rails — with a max() floor for devices that report none.