Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"

/** How long the floating pill stays up after the last scroll event (autoHide only). */
const IDLE_MS = 900
/** How long a jump sits in the live region before it is cleared for the next one. */
const ANNOUNCE_MS = 1200
/** Opacity the push takes away from a header at the moment it is fully shoved off. */
const PUSH_FADE = 0.85
/** An app bar taller than this is not an app bar; it is the screen. */
const MAX_TOP_INSET = 240

Installation

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

Prompt

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's padding-top, its scroll-padding-top, every header's sticky top, and every section's scroll-margin-top — one number, so the pinned header, a jump and a native Home all land in the same place.
  • env() has to be measured backcalc(env(...) + 44px) cannot be read off a custom property in JS, so a zero-width aria-hidden box of exactly that height sits at the top of the scroller and hands offsetHeight back to the maths. A ResizeObserver on 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 JSposition: sticky inside 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 writes opacity = 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 narrows touch-action over 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 to bg-foreground text-background rather 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"; a value that 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.

On This Page