Mobile

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.

Preview in your theme

Loading preview…

"use client"

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

export interface ListIndexBarEntry {
  /** Section id. Handed straight back through `onValueChange` — scrolling stays with the consumer. */
  key: string
  /** The glyph painted on the rail: one or two characters ("A", "#", "Ø"). */
  label: string
  /** Full section name, shown in the preview bubble and used as the option's accessible name. Defaults to `label`. */
  title?: string
  /** No rows behind this letter: it is dimmed, and both the drag and the keyboard skip over it. */
  empty?: boolean

Installation

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

Prompt

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-none and 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.

On This Page