List Index Bar
An edge-hugging A–Z rail that jumps a sectioned list from one continuous thumb drag, with a magnified preview bubble, dot compression and a full keyboard path.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/list-index-bar.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ListIndexBar" component (no gesture
library: Pointer Events, one ResizeObserver, no animation runtime).
Contract
- Export a forwardRef <div> extending React.HTMLAttributes<HTMLDivElement>
with "defaultValue" omitted from the native props.
- entries: { key: string; label: string; title?: string; empty?: boolean }[],
one entry per SECTION and in list order. label is the 1-2 character glyph
painted on the rail; title is the long name used by the bubble and as the
option's accessible name; empty marks a letter with no rows behind it.
- Controlled and uncontrolled: value?: string, defaultValue?: string,
onValueChange?: (key: string, source: "pointer" | "keyboard") => void.
- variant?: "rail" | "capsule" | "thumb", default "rail". Same geometry and
same hit-testing in all three; only the paint differs.
- side?: "right" | "left", default "right".
- showPreview?: boolean (default true), previewHoldMs?: number (default 550,
clamped 0..10000, 0 meaning clear immediately).
- disabled?: boolean, label?: string (accessible name of the listbox).
- The component never scrolls anything. It reports a key; the consumer owns
the scroller, typically a Map of section key -> element plus
scroller.scrollTo({ top: node.offsetTop }).
Behavior
- The rail is ONE hit-test surface, not a stack of buttons. Rows are
pointer-events:none and the root handles the pointer, so a finger sliding
down the column never leaves and re-enters a target.
- pointerdown: ignore secondary mouse buttons and refuse while disabled or
while entries is empty; cache the track's getBoundingClientRect once (it
cannot move during its own gesture); setPointerCapture on the element that
received the event, so a thumb drifting sideways off a 44px column keeps
driving the rail; focus the root, so a drag can be finished with the arrows.
- Index maths: index = floor(((clientY - rect.top) / rect.height) * count),
clamped into 0..count-1 — sliding past the last letter means "the very end",
not "nothing".
- Empty entries are never landed on: resolve to the nearest neighbour that has
rows, preferring the earlier one, so a dead "Q" between "P" and "R" resolves
upward and a slow drag never jumps forward past the letter under the thumb.
- One change per entry crossed, not one per pointermove: a ref mirrors the
committed key and is read AND written synchronously inside the handler.
Never derive that guard from state — 40 moves land inside a single row.
- touch-action: none on the root means the browser never fights the drag, so
the component never needs preventDefault and never needs a non-passive
listener.
- pointerup / pointercancel / lostpointercapture all end the gesture: release
the capture on the same element that took it, drop the cached rect, and
start the preview's hold timer. The timer is cleared on unmount and at the
start of the next gesture.
- Preview bubble: pinned to the ACTIVE ROW's centre, not to the raw finger Y,
so it snaps letter by letter and costs one render per entry instead of one
per frame. Its offset is clamped to 8%..92% so it never hangs off the ends.
Anchor it inside the MEASURED track, not the root: a percentage top resolves
against the containing block's padding box, and the root's padding is the
safe-area inset, so a root-anchored bubble drifts off its own row by most of
that inset on a notched phone.
It is aria-hidden: the listbox already reports selection and a second live
region would double-speak every row the thumb crosses.
- Keyboard, the equal path to every jump: the root is role="listbox",
tabIndex 0, with aria-activedescendant pointing at the active option.
ArrowUp / ArrowDown (and Left / Right) move one entry with rows, skipping
empties and clamping at the ends rather than wrapping; Home / End go to the
first / last entry with rows; PageUp / PageDown move five; Enter and Space
re-fire onValueChange for the entry you are already on (asking again is a
real request); any single printable character is type-ahead onto the first
entry whose label or title starts with it. Handled keys call
preventDefault; the consumer's own onKeyDown runs first and can cancel.
- Every keyboard jump raises the same bubble as a drag, then lets it expire.
- Compression instead of scrolling: a ResizeObserver on the track (never a
measurement inside the effect body — observe() delivers the first one)
gives rowHeight = height / count. Below ~13px per row the labels thin out
into 4px dots, keeping the first and last labelled, while every entry keeps
its slot — so a letter drawn as a dot is still reachable by drag. Unmeasured
(SSR, first paint, no ResizeObserver) means "assume it fits", which is also
what the server prints, so hydration matches.
- entries.length === 0 renders null. An index over nothing is not an empty
rail, it is no rail, and the list gets its full width back.
- disabled refuses every drag and every key and reports aria-disabled, but
keeps tabIndex 0: dropping a focused rail out of the tab order throws the
user back to <body>.
Rendering & styling
- Semantic tokens only. text-muted-foreground for idle glyphs,
text-foreground for the active one, text-muted-foreground/40 for empty
letters, border + bg-card/90 + backdrop-blur for the capsule surface,
bg-border for the thumb variant's hairline. The two highest-priority
surfaces — the preview bubble and the thumb pill — INVERT
(bg-foreground text-background) instead of taking a colour.
- The root is w-11 (44px): the letters are ~14px apart on purpose, and the
44px target is the column. The miss is repaid by correcting mid-gesture
against the bubble instead of lifting and tapping again.
- Safe area: the rail sits on a screen edge, so pad with
pt-[env(safe-area-inset-top)], pb-[env(safe-area-inset-bottom)] and
pr-[env(safe-area-inset-right)] / pl-[env(safe-area-inset-left)] to match
side. In landscape that inset is the notch.
- Type: text-[11px] leading-none font-semibold tracking-tight on the rail,
text-lg for the bubble's glyph, text-[10px] for its title line, which
truncates inside a max-w-40 bubble.
- The track carries role="presentation" so the options read as direct
children of the listbox; the hairline, the pill and the bubble are
aria-hidden; every option carries aria-selected plus an aria-label taken
from title (a glyph is not a name).
- Reduced motion: the only animations are the thumb pill's position tween and
the letter fade of the "thumb" variant, both carrying
motion-reduce:transition-none. Nothing is hidden and nothing stops working
when motion is off, and the pill never tweens while a finger is down.
- focus-visible:outline-none on the root plus group-focus-visible:ring-2
ring-ring on the track, so the ring hugs the visible column instead of the
transparent hit area. Merge the consumer's className with cn().
Customization levers
- Density: w-11 is the hit column and w-7 the painted track; MIN_LABEL_PX
(13) is the legibility floor that decides when letters become dots — raise
it for a larger type scale, lower it to keep more letters on a short screen.
- Variants: "rail" for a plain list, "capsule" over photos or a map, "thumb"
when a permanent alphabet is visual noise. Adding a fourth is one branch in
the class merge; the geometry is shared.
- Preview: previewHoldMs tunes the linger, showPreview turns the bubble off
for a rail with only a handful of entries, and the bubble's content is two
lines (glyph + title) you can cut to one.
- Follow mode: the bubble is row-snapped by design; pin it to the raw pointer
Y instead if you want a continuously sliding lens, at the cost of a render
per frame (drive it with a transform, not with state).
- Tokens: swap bg-foreground/text-background for bg-primary/
text-primary-foreground if the brand wants the bubble tinted; the empty
letters' /40 alpha is the one number to tune for contrast.
- Scroll feel: source is handed to onValueChange precisely so a drag can use
behavior "auto" (the list must track the finger) while a key press uses
"smooth".
- Haptics: the commit point is the single place a navigator.vibrate call
belongs, if the platform supports it.Concepts
- Rail as a hit-test — the letters are not buttons. Rows are
pointer-events-noneand one captured pointer turns a Y coordinate into an index, which is why a finger can slide the whole alphabet without ever crossing a target boundary — and why the 44px rule is met by the column rather than by each letter. - Row-granular commit — the change fires once per entry the thumb crosses, guarded by a ref that is read and written inside the same handler. State would lag by a render and re-fire the same section forty times in one row.
- Dot compression — when the viewport cannot give every entry a legible row, labels thin into dots instead of the rail scrolling or dropping letters. Every entry keeps its slot, so a letter drawn as a dot is still reachable by drag; a scrolling index would be an index you have to find first.
- Preview bubble — the finger is on top of the letter it is choosing, so the choice is echoed beside it, snapped to the row rather than glued to the pointer. It is
aria-hidden, because the listbox already reports selection and a second announcement would double-speak every crossing. - Empty-section skip — letters with no rows are dimmed and unlandable; the drag resolves to the nearest neighbour with rows, preferring the earlier one so the list never jumps forward past the letter under the thumb.
- Gesture parity — arrows, Home/End, PageUp/PageDown, Enter and type-ahead reach every section a drag can, raise the same bubble, and report
source: "keyboard"so the consumer can glide instead of snapping.
Reachability Pull
One-handed mode as a wrapper: a pull on the home-indicator strip brings the whole screen down into the thumb arc — sliding, shrinking or leaning into a corner — with a button, arrow keys, Escape, a tap and a timeout that all send it back.
Action Sheet
An OS-style sheet of grouped choices raised from the bottom edge, with a destructive tone, a detached Cancel, drag-to-dismiss and safe-area padding.