Build an A-to-Z section list — sticky headers + jump-to-letter
By the end of this recipe you'll have a long list split into sections (A, B, C… or category), sticky section headers, and a vertical letter strip on the right that jumps you straight to a section.
Overview
A directory list — contacts, ingredients, suppliers — partitioned by first letter or category. Sticky headers mark the section. A jump-to-letter strip lets the user reach any section without scrolling.
Without grouping, a 200-name contact list is a wall. With grouping plus a side index, the user lands on "M" in one tap. That's the difference between "useful directory" and "search box pretending to be a directory."
Sticky headers stick to the top of the list, not the top of the screen. Otherwise they overlap the page header, and the section label sits over your nav like an apology. Constrain the sticky to its scrolling parent.
Skip this recipe if your list is under 30 items — group headers cost more visual weight than they save. Use a flat list. Also skip if items don't have a natural alphabetical anchor (numeric IDs aren't a section key).
What you'll build
Default A-Z, sticky header pinned mid-scroll, and the index strip.
Suppliers
Suppliers
Suppliers (by region)
Required pieces
A bucketing function, a sticky header, and an optional letter strip.
@corelithzw/react exports used here: Stack, RowCard, Input. The letter strip is plain HTML + one scrollIntoView call.
Step-by-step build
Group with a Map — preserves insertion order
Sort first, then group. A Map keeps the order you insert keys in, so iterating gives you A, B, C without a second sort. Plain objects don't guarantee key order across engines — use Map.
Names starting with a digit/symbol fall into a single # bucket at the end. Localecompare uses base sensitivity so "Á" sorts with "A".
Render — give each header a stable id for jumping
The header's id is the jump anchor. scrollIntoView on that node is the entire jump-to-letter implementation. Use section-${'$'}{letter} so it can't collide with another page id.
Jump strip — one click, one scrollIntoView
The strip is a column of letter buttons. Tapping one scrolls its section header into view with block: 'start'. The whole "implementation" is six lines — don't reach for a library.
scrolls
Sticky CSS — pin to the LIST, not the page
The sticky header's containing block is the scrolling element. If your list isn't scrollable (the page scrolls instead) the sticky pins to the viewport — which means it overlaps your top nav. Put overflow: auto on the list and the sticky behaves.
Strip is absolutely positioned inside the list.
Final composition
Group + render + jump. About 70 lines.
Suppliers
Variations
Three forks. Same shape, different group key.
Group by category
Swap the letter key for a category field (region, type, status). Drop the jump strip — category names are too long for a vertical index.
const key = item.region; // drop jump strip + the // alpha sort — categories // have their own order const order = ['Harare', 'Bulawayo']; return order.map(k => …);
Search-narrows-list
Add an Input above. Filter items before grouping. The same component handles search and browse — no separate "search results" view.
const filtered = items.filter(i => i.name.toLowerCase() .includes(q.toLowerCase()) ); const groups = groupByLetter(filtered); // jump strip auto-shrinks
Collapsible sections
Wrap each section in <details>. Useful when sections are uneven and you want to fold "A" once you're past it.
<details open>
<summary className="section-header">
{letter} ({rows.length})
</summary>
{/* rows */}
</details>
Accessibility
A jump strip is a real navigation control — treat it like one.
- Each section is a
<section aria-labelledby>. Section semantics + the section header gives screen readers "A, region, 12 items". - Jump strip is
<nav aria-label>. Screen readers announce it as "Jump to letter, navigation" — distinct from the list itself. - Each jump button has an
aria-label. A bare "M" is ambiguous out of context — "Jump to M" is unambiguous. scrollIntoViewrespectsprefers-reduced-motion. If you setbehavior: 'smooth', also check the media query and use'auto'when the user has reduced motion.- The list itself is still a real list.
role="list"on the Stack so the item count is announced per section, not lost to the section wrapper.