Home/ Cookbook/ Lists/ Grouped by section

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.

Lists 2 primitives · 1 block · 0 patterns ~20 min React · @corelithzw/react

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.

Huchu

Suppliers

A
Adams Hardware7
B
Build It12
Brick & Block3
C
Cement Co9
ABCDEFG
01 Default — letters + jump strip
Huchu

Suppliers

M
Mash Holdings4
Metro Steel18
Mhondoro Mill2
N
Nyaradzo Ltd1
LMNO
02 Sticky M, current letter highlighted
Huchu

Suppliers (by region)

Harare
Adams HardwareCBD
Build ItEastlea
Bulawayo
Cement CoBelmont
Mutare
Metro SteelSakubva
03 By category (region), no index

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

01

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.

Code-only step

Names starting with a digit/symbol fall into a single # bucket at the end. Localecompare uses base sensitivity so "Á" sorts with "A".

Step 1 · groupByLetter()@corelithzw/react
02

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.

A
Adams Hardware7
B
Build It12
Step 2 · Render
03

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.

List area
scrolls
ABCDEFG
Step 3 · Jump strip
04

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.

Sticky parent = the list, not the page.
Strip is absolutely positioned inside the list.
Step 4 · CSS

Final composition

Group + render + jump. About 70 lines.

Suppliers

A
Adams Hardware7
B
Build It12
ABCDMN
SupplierList.tsx@corelithzw/react

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.
  • scrollIntoView respects prefers-reduced-motion. If you set behavior: '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.