A–Z Index List
A contacts-style list with sticky letter headers and a side A–Z rail you can tap or scrub, plus search and multi-select.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/alpha-index-list.json'use client'
import { useRef, useState } from 'react'
import './alpha-index-list.css'
export interface AlphaIndexListProps {
names: string[]
selectable?: boolean
onSelect?: (names: string[]) => void
fill?: 'dark' | 'light'
className?: string
}
const AZ = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
/** Alphabetical list with sticky headers and scrub rail. */
export function AlphaIndexList({ names, selectable = true, onSelect, fill = 'dark', className }: AlphaIndexListProps) {
const [q, setQ] = useState('')
const [sel, setSel] = useState<string[]>([])
const [bub, setBub] = useState<string | null>(null)
const list = useRef<HTMLDivElement>(null)
const rail = useRef<HTMLDivElement>(null)
const scrub = useRef(false)
const shown = [...names].sort((a, b) => a.localeCompare(b)).filter((n) => n.toLowerCase().includes(q.trim().toLowerCase()))
const groups = AZ.map((L) => [L, shown.filter((n) => n[0].toUpperCase() === L)] as const).filter(([, xs]) => xs.length)
const has = new Set(groups.map(([L]) => L))
const jump = (L: string) => {
const el = list.current?.querySelector<HTMLElement>(`[data-l="${L}"]`)
if (el && list.current) list.current.scrollTop = el.offsetTop - list.current.offsetTop
setBub(L)
}
const fromY = (y: number) => { const r = rail.current!.getBoundingClientRect(); const i = Math.max(0, Math.min(25, Math.floor(((y - r.top) / r.height) * 26))); const L = AZ.slice(i).find((x) => has.has(x)) ?? AZ.slice(0, i).reverse().find((x) => has.has(x)); if (L) jump(L) }
const toggle = (n: string) => { const next = sel.includes(n) ? sel.filter((x) => x !== n) : [...sel, n]; setSel(next); onSelect?.(next) }
return (
<div className={['sd-aix', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-aix__s"><input type="search" placeholder="Search people" aria-label="Search people" value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="sd-aix__l" ref={list} onScroll={() => !scrub.current && bub && setBub(null)}>
{groups.map(([L, xs]) => (
<section key={L} data-l={L} aria-labelledby={`sd-aix-${L}`}>
<h5 id={`sd-aix-${L}`}>{L}</h5>
<ul>{xs.map((n) => <li key={n}><label>{selectable && <input type="checkbox" checked={sel.includes(n)} onChange={() => toggle(n)} />}<span className="sd-aix__av" aria-hidden>{n.split(' ').map((w) => w[0]).join('').slice(0, 2)}</span>{n}</label></li>)}</ul>
</section>
))}
{!groups.length && <div className="sd-aix__none">No one matches “{q}”</div>}
</div>
<div className="sd-aix__rail" ref={rail} aria-label="Jump to letter"
onPointerDown={(e) => { scrub.current = true; (e.currentTarget as Element).setPointerCapture?.(e.pointerId); fromY(e.clientY) }}
onPointerMove={(e) => scrub.current && fromY(e.clientY)} onPointerUp={() => { scrub.current = false; setTimeout(() => setBub(null), 500) }}>
{AZ.map((L) => <button key={L} type="button" disabled={!has.has(L)} data-on={bub === L || undefined} aria-label={`Jump to ${L}`} onClick={(e) => { if (e.detail === 0) jump(L) }}>{L}</button>)}
</div>
{bub && <div className="sd-aix__bub" aria-hidden>{bub}</div>}
<div className="sd-aix__f" aria-live="polite"><span>{selectable ? `${sel.length} selected` : `${shown.length} people`}</span>{selectable && <button type="button" disabled={!sel.length}>Add to group</button>}</div>
</div>
)
}.sd-aix { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-aix[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-aix :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-aix :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-aix .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-aix { position: relative; width: min(100%, 320px); grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; overflow: hidden; }
.sd-aix__s { padding: 10px; border-bottom: 1px solid var(--line); }
.sd-aix__s input { width: 100%; height: 34px; padding: 0 12px; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; box-sizing: border-box; }
.sd-aix__s input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-aix__l { height: 330px; overflow-y: auto; padding-right: 22px; scroll-behavior: smooth; }
.sd-aix h5 { position: sticky; top: 0; z-index: 1; margin: 0; padding: 4px 12px; font-size: 11.5px; font-weight: 700; background: var(--bg); border-bottom: 1px solid var(--line); }
.sd-aix ul { list-style: none; margin: 0; padding: 0; }
.sd-aix li label { display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-size: 13.5px; cursor: pointer; }
.sd-aix li label:hover { background: var(--soft); }
.sd-aix li input { appearance: none; width: 18px; height: 18px; margin: 0; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--line); display: grid; place-items: center; flex: none; }
.sd-aix li input:checked { background: var(--fg); box-shadow: none; }
.sd-aix li input:checked::after { content: ''; width: 7px; height: 4px; border: solid var(--bg); border-width: 0 0 2px 2px; transform: translateY(-1px) rotate(-45deg); }
.sd-aix__av { width: 28px; height: 28px; border-radius: 50%; background: var(--soft); display: grid; place-items: center; font-size: 11px; font-weight: 700; flex: none; }
.sd-aix__rail { position: absolute; right: 2px; top: 60px; bottom: 44px; width: 20px; display: flex; flex-direction: column; justify-content: space-between; align-items: center; touch-action: none; }
.sd-aix__rail button { font-size: 10.5px; font-weight: 700; line-height: 1; padding: 1px 4px; border-radius: 4px; }
.sd-aix__rail button:disabled { color: var(--mute) !important; opacity: 0.4; }
.sd-aix__rail button[data-on] { background: var(--strong) !important; color: var(--on) !important; }
.sd-aix__bub { position: absolute; right: 34px; top: 50%; width: 52px; height: 52px; margin-top: -26px; border-radius: 14px; display: grid; place-items: center; font-size: 26px; font-weight: 700; background: var(--strong) !important; color: var(--on) !important; pointer-events: none; animation: sd-aix-b 120ms ease-out; }
@keyframes sd-aix-b { from { transform: scale(0.8); opacity: 0; } }
.sd-aix__f { display: flex; justify-content: space-between; align-items: center; height: 44px; padding: 0 12px; border-top: 1px solid var(--line); font-size: 12.5px; color: var(--mute); }
.sd-aix__f button { height: 30px; padding: 0 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-aix__f button:disabled { opacity: 0.35; }
.sd-aix__none { padding: 40px 12px; text-align: center; color: var(--mute); font-size: 13px; }
@media (prefers-reduced-motion: reduce) { .sd-aix *, .sd-aix *::before, .sd-aix *::after { animation: none !important; transition: none !important; } }Overview
Names are grouped under sticky letter headings. The rail on the right jumps to a letter on tap and follows your finger when you drag down it; letters with no names are dimmed.
Search filters live. Tapping a row toggles selection; the footer counts selected people.
When to use
- Contacts, country pickers, long alphabetical lists on mobile.
When not to use
- Short lists that fit on screen.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/alpha-index-list.json$
npx shadcn@latest add https://sundaeui.dev/r/alpha-index-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/alpha-index-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/alpha-index-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy alpha-index-list.tsx and alpha-index-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AlphaIndexList } from '@/components/sundae/alpha-index-list'
<AlphaIndexList />Props
| Prop | Type | Default |
|---|---|---|
names*Items. | string[] | — |
onSelectCalled on selection change. | (names: string[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rail letters are buttons; dimmed ones are disabled.
- Rows are checkboxes in a list with group headings.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-aix; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is A–Z Index List free?
- Yes. A–Z Index List is free for personal and commercial projects, with full source on this page.
- How do I install A–Z Index List?
- Run npx shadcn@latest add https://sundaeui.dev/r/alpha-index-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does A–Z Index List support keyboard and screen readers?
- Rail letters are buttons; dimmed ones are disabled. Rows are checkboxes in a list with group headings.
- Does A–Z Index List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All navigation →Pagination
Navigation · Refined- Pro
Command Palette
Navigation · Refined Segmented Control
Navigation · RefinedProgress Steps
Navigation · RefinedToolbar Tooltips
Navigation · RefinedUnderline Tabs
Navigation · Refined