Sticky Section List
A grouped mobile list whose section headers pin to the safe-area line and push each other out, with a thumb-arc pager that walks the sections and reports the one being read.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/sticky-section-list.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StickySectionList" component — a grouped
list for a phone, whose section headers pin to the safe-area line, get pushed out
by the next section, and can be walked with a pager parked in the thumb arc.
React + lucide-react only: no scroll library, no gesture library, no
IntersectionObserver (it reports crossings, not "which section owns the line").
Contract
- "use client". forwardRef<HTMLDivElement, StickySectionListProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue">; the rest props
spread onto the root.
- sections: { key: string; label: string; meta?: string; content: ReactNode }[].
content is opaque — rows, a photo grid, a paragraph. The component pins a
header above it and never assumes its shape.
- variant?: "band" | "floating" | "inset", default "band". Same pin geometry,
three paints: a full-bleed band, a floating pill that costs the rows no flow
space, a quiet label above a rounded card.
- value?: string / defaultValue?: string / onValueChange?: (key, source) => void,
with source: "scroll" | "jump". Controlled and uncontrolled both work; the
callback fires in both modes.
- topInset?: number, default 0, clamped 0..240 — the height of the app bar the
list scrolls under, ADDED to env(safe-area-inset-top). A value taller than 240
is not an app bar, it is the screen.
- autoHide?: boolean, default false — retire the header while nothing scrolls.
Only variant="floating" honours it; a band or an inset label would leave a hole.
- showJump?: boolean = true, jumpSide?: "right" | "left" = "right",
jumpBehavior?: "auto" | "smooth" = "auto", label?: string (the scroll region's
accessible name), emptyState?: ReactNode.
- Export the StickySectionListVariant and StickySectionListSource unions and the
section type; mirror the state onto the root as data-variant and
data-active-section.
Behavior — the pin line is a number, not zero
- The pin line is calc(env(safe-area-inset-top) + topInset px), published once as
a CSS custom property on the root and then reused four times: the scroller's
padding-top, its scroll-padding-top, every header's sticky `top`, and every
section's scroll-margin-top. Everything that has to agree about "the line"
reads the same variable.
- env() inside calc() cannot be read back off a custom property, so a zero-width
aria-hidden box of exactly that height sits at the top of the scroller and is
measured with offsetHeight whenever a pixel number is needed. A ResizeObserver
watches that box and the scroller: rotating the phone changes the safe-area
inset and re-fires the whole pass for free.
- Active section = the LAST section whose offsetTop <= scrollTop + pin + 1. The
scroller is position: relative so offsetTop is measured against it; the +1
absorbs sub-pixel layout, so a section resting exactly on the line is the one
being read, not the one before it.
- The push is CSS. Each header is position: sticky inside its own section, so the
next section's header shoves the current one out from underneath with no JS at
all. What CSS cannot express is HOW FAR ALONG that shove is, so one pass reads
the pinned header's rect and writes opacity = 1 - ratio * 0.85, where ratio is
0 while the header rests on the line and 1 the moment its bottom edge reaches
it. Compute it from the rect, not from flow position: that is the same maths
for a band in flow and for a pill hanging out of a zero-height sticky box.
Reset the opacity on the header that stops being the active one — React reuses
nodes, and a stale 0.15 on a recycled header looks like a rendering bug.
- No pointer gesture is invented. The gesture here is the native flick, so the
component never calls preventDefault, never narrows touch-action over content
that may hold a horizontal rail, and reads the result from a plain onScroll.
Momentum scrolling fires far more often than the screen repaints and the pass
above reads layout, so coalesce it into one requestAnimationFrame. Set
overscroll-behavior-y: contain so a flick past the end does not drag the page.
- Section-level movement gets buttons instead: a pager of prev / current / next
in the thumb arc, which is keyboard-reachable for free. Its middle key
re-anchors the section being read (the "put the header back on the line" move
that a stray flick makes you want). At the ends, the arrows report aria-disabled
and their handler refuses — never the native disabled attribute, because the
browser blurs a node the instant it becomes disabled and a thumb already
standing on that key would drop focus onto <body>.
- Jumping is scrollTo({ top: node.offsetTop - pin }), which lands the section's
top exactly on the line — the same landing the pager, a controlled value and
defaultValue all produce. "smooth" glides and therefore reports every section
it passes through; prefers-reduced-motion forces "auto".
- Controlled: a `value` that is not an echo of what the scroll last reported is a
request to go there. Keep the current key in a ref written synchronously by
both paths, and make the jump effect DOM-only: an echo can then never turn into
a scroll loop. defaultValue is honoured exactly once, on the first commit
("open the list at this section"), then it is just a default. A key that is no
longer in sections resolves to the first section rather than freezing the pager
on a group that was filtered away.
- Seed the "last notified key" with value ?? defaultValue ?? sections[0].key, so
the first measurement after mount is not reported as a change.
- autoHide is a 900ms idle timer: the pill is up on the first paint (a list nobody
has touched would otherwise show no grouping at all), retires when the scrolling
stops and comes back on the next scroll event. Mirror "is scrolling" in a ref as
well as state, so forty scroll events do not each request a render.
- Empty: sections=[] renders emptyState centred in the region and drops the pager
entirely — a pager with no destinations is a control that cannot be used. An
empty SECTION is different: it keeps its header, its slot in the counter and its
place in the arrow walk. An empty group is information, not a hole to skip.
- Cleanup: the coalescing rAF, the idle timer, the announcement timer, the
ResizeObserver and the matchMedia subscription are all cancelled on unmount and
on dependency change.
Rendering & styling
- Semantic tokens only, monochrome by design: root rounded-2xl border bg-card
text-card-foreground; headers bg-background/90 + backdrop-blur (they must stay
legible over whatever slides under them); meta text-muted-foreground; inset
bodies rounded-2xl border bg-card; focus-visible:ring-2 ring-ring, ring-inset on
the scroll region whose ring would otherwise be clipped. The pager is the one
element that outranks the content, so it INVERTS — bg-foreground text-background
— instead of taking a colour.
- Type scale is small and tight: header 10-11px uppercase semibold, rows 13px,
meta 10-11px tabular-nums. Labels truncate rather than wrap, in the header and
again inside the pager, so a sentence-long section name cannot change the
header's height or grow the pager past the thumb arc.
- Safe area on all four edges that matter: the pin line eats
env(safe-area-inset-top); the scroller's bottom padding is
env(safe-area-inset-bottom) plus 76px when the pager is up (12px without), so
the last rows are never stuck under it; the pager itself is padded by
env(safe-area-inset-left / right) so it clears a rounded corner.
- Accessibility:
- The scroll region is role="region" + aria-label + tabIndex 0, so Tab reaches
it and every native scroll key keeps working untouched: Arrow Up / Down,
PageUp / PageDown, Home / End, Space. Deliberately no custom key handler
inside it — rebinding a key inside a scroll region breaks the platform
behaviour the region exists for.
- Each section is role="group" + aria-labelledby pointing at its own h3, so a
screen reader announces which group a row belongs to.
- Pager keys are real buttons: Enter / Space are free, aria-label spells out the
destination ("Next section, February 2026"), and the middle key says "back to
the start of X".
- A polite sr-only role="status" announces JUMPS ONLY, and is cleared after
1.2s so pressing the same key twice is announced twice. Announcing every
section a flick passes would be a screen-reader storm.
- prefers-reduced-motion, subscribed with useSyncExternalStore over matchMedia
and never read during render: the push fade goes to 0 and smooth jumping falls
back to "auto". The pinning, the pushing and the pager are untouched — they
are layout and navigation, not decoration.
- Every touch target is 44px: the pager keys are h-11 min-w-11 and the rows are
min-h-11. Nothing depends on hover; hover is a tint on top of a state that is
already visible.
Customization levers
- topInset is the integration seam: 0 for a bare list, 44-56 under an app bar,
and whatever your collapsing header measures at. Everything downstream (padding,
scroll-padding, sticky top, the measured line) follows it automatically.
- variant is the paint, not the machine: "band" for text rows, "floating" over a
photo / card grid where a band would steal a row of tiles, "inset" for the
settings shape. Add a fourth by adding one entry to each of the header / meta /
section / body class maps — the geometry is shared.
- autoHide + variant="floating" is the "no scrollbar to read" answer; raise the
900ms idle if your users scroll in bursts.
- showJump={false} when the screen already has a section control (an A-Z rail, a
segmented control, a month picker) — then drive the list by passing value, and
the bottom clearance shrinks on its own. Keep at least one path to every
section: a list whose only way to a group is a long flick is not navigable.
- jumpSide follows the thumb; jumpBehavior="smooth" is worth it only for short
lists, where seeing the sections fly past is orientation rather than noise.
- Height belongs to the consumer: className="h-[440px]" / "h-dvh" merges last via
cn(). Give the root a height or the inner scroller has no range to scroll.
- Style the pager by targeting data-variant on the root, or hide it and render
your own control that calls the same "set value" — the component treats any
non-echo value as a jump, so an external control needs no new API.Concepts
- Pin line, not y=0 — on a phone a section header parks below the notch and below whatever app bar the list scrolls under, so the resting line is
env(safe-area-inset-top) + topInset. It is published once as a CSS variable and consumed four ways: the scroller'spadding-top, itsscroll-padding-top, every header's stickytop, and every section'sscroll-margin-top— one number, so the pinned header, a jump and a nativeHomeall land in the same place. - env() has to be measured back —
calc(env(...) + 44px)cannot be read off a custom property in JS, so a zero-widtharia-hiddenbox of exactly that height sits at the top of the scroller and handsoffsetHeightback to the maths. AResizeObserveron that box means rotating the phone, which changes the inset, re-runs the whole pass without a resize listener. - The push is CSS, the fade is JS —
position: stickyinside each section already makes the arriving header shove the current one out from underneath; that part costs nothing and cannot stutter. What CSS cannot express is how far along the shove is, so one rAF-coalesced pass reads the pinned header's rect and writesopacity = 1 - ratio * 0.85. Turning the fade off (reduced motion) changes nothing about the layout, which is the test for whether an effect was decoration. - No gesture was invented — the gesture is the native flick, so the component never calls
preventDefault, never narrowstouch-actionover rows that may hold a horizontal rail, and never fights momentum scrolling; it only reads the result. Everything discrete — walk to the previous section, re-anchor this one — is a real 44px button, which makes the keyboard path free instead of bolted on. - The thumb arc is the other end of the screen — the headers live at the top, where a one-handed thumb cannot reach, so the control that moves between them is parked at the bottom edge, inside
env(safe-area-inset-bottom), on the side you name. It is the one element that outranks the content, so it inverts tobg-foreground text-backgroundrather than taking a colour, and the list reserves 76px of clearance so the last rows never hide under it. - Echo, not loop — the scroll reports the section under the line with source
"scroll"; avaluethat is not an echo of that report is a request to go there, executed as a DOM-only scroll. Both paths write the current key to the same ref synchronously, so a controlled parent that mirrors every change back cannot start a jump-report-jump loop — and the pager announces jumps only, because reading out every section a flick passes would be a screen-reader storm.
Download Row
A per-transfer mobile row whose rate and ETA disappear the moment bytes stop arriving, with pause, resume, retry and a press-and-hold cancel that answers to the keyboard.
Pull Menu
An overscroll quick-action menu: pull a list past its top to reveal actions, the one under your thumb arms as you keep pulling or slide sideways, and releasing runs it.