List
Vertical rows of items — single-line, two-line, with leading and trailing content, or as key–value pairs.
Also called Structured List, Description List, Definition List, Item List — in this system all of them are List.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Key–value pairs
A description list, not a table with hidden headers. The term column is fixed-width so every value starts on the same edge.
One line or two
One line for scanning a long set; two when the metadata is what tells items apart. Never three — that is a Card.
List or table
Once a second value has to align across rows, the reader is doing a table’s work without a table’s sorting, alignment or headers.
When the whole row is the target
If the row navigates, the row is the button — not a link on the label. A trailing chevron says so, and any secondary action needs to stop propagation.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Leading icon, a title and one line of metadata, a trailing status, and a chevron saying the whole row goes somewhere.
- Row height40px one line, 52px two
Both comfortably above the touch minimum. Two-line rows earn their extra height only when the metadata is what tells items apart.
- Horizontal padding14px
Matches the container’s own padding, so a list inside a card does not look inset twice.
- Leading gap12px
Fixed regardless of what is in the slot, so an icon row and an avatar row share a text edge.
- Title13px, --ds-fg
The only full-contrast text in the row. Everything else is metadata and steps down.
- Metadata12px, --ds-fg-muted
One step down in size, two in colour. Equal weight makes the row read as two titles and the list stops being scannable.
- TrailingRight-aligned, shrink-0
A badge, a count, a chevron or an action. It never shrinks — the title truncates instead.
- Divider insetAligned to the title
When there is a leading element, the rule starts at the text. Full-width rules make the list read as a table.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | List background |
| --ds-border-subtle | — | Container edge and row dividers |
| --ds-layer-hover | — | Row hover on an interactive list |
| --ds-layer-selected | — | Selected row — never the hover value |
| --ds-fg | — | Row titles |
| --ds-fg-muted | — | Metadata, leading icons, key column |
| --ds-fg-disabled | — | The trailing chevron |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-3 | Leading gap | |
| --space-3-5 | Row horizontal padding |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-lg | Container corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-label | Row titles |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Compact | 32px | 4px 12px | — | 12px | — | — | — | Dense sets a user scans rather than reads — a file list, a log. |
| One line | 40px | 8px 14px | — | 13px | — | — | 44px on coarse pointers | The default for a scannable set. |
| Two lines | 52px | 10px 14px | — | — | — | — | — | When the metadata is what distinguishes items. |
| With avatar | 56px | — | 12px | — | — | — | — | A 32px avatar plus padding. Larger avatars belong in a Card. |
| Key column | — | — | — | — | 7rem | 12rem | — | Fixed, so every value starts on the same edge. |
| Measure | — | — | — | — | — | 40rem | — | A list stretched across a wide page leaves the trailing content marooned from the title. |
<ul><li>…</li></ul>
<dl> for term / definition pairsNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Row titles owe 4.5:1. Metadata is content too and owes the same — "secondary" does not mean exempt.
- The selected row must be distinguishable from the hovered row, or nothing is legible while the pointer is in the list.
- Dividers owe 3:1 only if they are the sole separation. If rows have their own hover surface, the rules are decorative.
- A trailing chevron may use the disabled tone — it is an affordance, and the row is also pressable as a whole.
Keyboard
| Tab | Stops on each interactive row, then on any secondary action inside it. |
| Enter | Activates the row. Space too, when the row is a button rather than a link. |
| ↑ / ↓ | Optional roving focus for a long list, so Tab does not stop fifty times. If you add it, the list is a listbox and needs those roles. |
| Home / End | With roving focus, jumps to the first or last row. |
Screen readers
- The list announces its size: "list, 12 items". That is the single most useful thing the markup provides.
- Each row should read as one coherent phrase: "api-gateway, deployed 4 minutes ago, Live".
- An empty list needs an explicit message. A silent empty container is indistinguishable from one that failed to load.
Focus & touch
- Focus order follows DOM order, which must follow visual order. When a row is removed, focus moves to the next row or to the list container — never to the body. A secondary action inside a pressable row must stop propagation on both click and keydown.
- Rows go to 44px, which one-line rows already nearly meet. A secondary action inside a pressable row needs its own 44px target and enough separation that a thumb aiming at one does not hit the other — in practice that means moving the action to a swipe or an overflow menu on small screens.
| Attribute | Applied to | Notes |
|---|---|---|
| <ul> / <ol> / <li> | The list | The item count comes from the markup. A stack of divs announces nothing. |
| <dl> / <dt> / <dd> | Key–value pairs | The term/definition relationship is what a description list is for, and it is announced. |
| role="listbox" / "option" | A selectable list | Only when the list holds a selection. A navigable list stays as links or buttons. |
| aria-current="page" | The row matching the current view | Distinct from selection — it means "you are here". |
| aria-label | A secondary action | Must name the row: "More actions for api-gateway". Forty identical buttons otherwise. |
| list-style: none | Styled lists | Safari removes list semantics when list-style is none. Add role="list" back explicitly. |
Example usage
1import { List, ListItem } from '@/ui/Display'23<List aria-label="Deployments">4 {deployments.map((d) => (5 <ListItem6 key={d.id}7 leading={<GitBranch />}8 title={d.name}9 description={d.meta} // metadata, not a second title10 trailing={<Badge tone={d.tone}>{d.status}</Badge>}11 onClick={() => open(d)} // the WHOLE row is the target12 />13 ))}14</List>1516// A secondary action inside a pressable row must stop propagation on both17// click and keydown, or the user aims at the menu and navigates away.18<button19 aria-label={`More actions for ${d.name}`}20 onClick={(e) => { e.stopPropagation(); openMenu() }}21 onKeyDown={(e) => e.stopPropagation()}22>23 <MoreHorizontal />24</button>2526// Key–value pairs are a description list, not a table with hidden headers.27<dl className="grid grid-cols-[minmax(7rem,auto)_1fr] gap-x-4 gap-y-2.5">28 {pairs.map(([term, value]) => (29 <React.Fragment key={term}>30 <dt>{term}</dt>31 <dd>{value}</dd>32 </React.Fragment>33 ))}34</dl>3536// Safari drops list semantics when list-style is none. Add the role back.37<ul role="list" className="list-none">…</ul>Framework-free HTML
<!-- role="list" because Safari removes list semantics when list-style
is none, which every styled list sets. -->
<ul role="list" class="ds-list" aria-label="Deployments">
<li>
<a href="/d/4021" class="ds-list__row">
<svg aria-hidden="true">…</svg>
<span class="ds-list__text">
<span class="ds-list__title">api-gateway</span>
<span class="ds-list__meta">Deployed 4 minutes ago</span>
</span>
<span class="ds-badge">Live</span>
<svg aria-hidden="true">…</svg>
</a>
</li>
</ul>
<!-- Term / definition pairs. The relationship is announced. -->
<dl class="ds-kv">
<dt>Region</dt>
<dd>Europe (London) · eu-west-2</dd>
<dt>Duration</dt>
<dd>42 seconds</dd>
</dl>
<!-- An empty list needs to say so. Silence reads as a failed load. -->
<p class="ds-list__empty">No deployments yet</p>CSS
.ds-list { list-style: none; margin: 0; padding: 0; }
.ds-list__row {
display: flex;
align-items: center;
gap: 12px; /* fixed, so icon rows and avatar rows
share one text edge */
min-block-size: 40px;
padding-inline: 14px;
padding-block: 8px;
}
.ds-list__row:hover { background: var(--ds-layer-hover); }
/* Must differ from hover, or nothing is legible while the pointer is in
the list. */
.ds-list__row[aria-current='page'] {
background: var(--ds-layer-selected);
}
.ds-list__text { display: flex; flex-direction: column; min-inline-size: 0; }
.ds-list__title { color: var(--ds-fg); font-size: 13px; }
/* One step down in size, two in colour. Equal weight makes the row read as
two titles. */
.ds-list__meta { color: var(--ds-fg-muted); font-size: 12px; }
.ds-list__title,
.ds-list__meta { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The title truncates; the trailing content never shrinks — it is often the
reason the user is scanning. */
.ds-list__row > .ds-badge { flex: 0 0 auto; }
/* Inset to the text: a full-width rule makes the list read as a table. */
.ds-list > li + li > .ds-list__row::before {
content: '';
position: absolute;
inset-inline: 14px 0;
inset-block-start: 0;
block-size: 1px;
margin-inline-start: calc(16px + 12px);
background: var(--ds-border-subtle);
}
.ds-kv {
display: grid;
/* Fixed term column, so every value starts on the same edge. */
grid-template-columns: minmax(7rem, auto) 1fr;
gap: 10px 16px;
}
@media (pointer: coarse) {
.ds-list__row { min-block-size: 44px; }
}Component API
List
| Prop | Type | Default | Description |
|---|---|---|---|
| as | 'ul' | 'ol' | 'dl' | 'ul' | ol when the order is meaningful, dl for term/definition pairs. The element carries the semantics. |
| aria-label | string | — | Names the set. A list of twelve rows with no label is twelve announcements with no context. |
| dividers | boolean | true | Inset to the text when there is a leading element. |
| density | 'compact' | 'default' | 'default' | Compact for sets that are scanned rather than read. |
ListItem
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | ReactNode | — | The only full-contrast text in the row. |
| description | ReactNode | — | One line of metadata. Two lines means this should be a Card. |
| leading | ReactNode | — | An icon or an avatar. The gap is fixed regardless of which. |
| trailing | ReactNode | — | A badge, a count or an action. Never shrinks — the title truncates instead. |
| onClick | () => void | — | Makes the whole row a button. Secondary actions inside must stop propagation. |
| href | string | — | Makes the whole row a link. Prefer this over onClick when it navigates. |
Professional tips
- Give an empty list an explicit message. A blank container is indistinguishable from one that failed to load, and users refresh.
- Truncate the title from the end but keep the trailing content pinned. The status is frequently the reason the user is scanning at all.
- Add a sticky header with a count for long lists. "48 deployments" answers a question the user would otherwise scroll to guess at.
- For selectable lists, put the checkbox in the leading slot and keep the row clickable for navigation. Two behaviours, two targets, no ambiguity.
- If every row has an overflow menu, consider whether the actions belong at the top of the list instead — one toolbar beats fifty menus.
Performance
- Virtualise past roughly 200 rows, and add aria-setsize and aria-posinset when you do — otherwise a windowed list reports "3 of 20" for 400 items.
- Give rows a fixed height where you can. Variable heights force a measurement pass per row and make virtualisation far harder.
- Memoise the row component on its item id. A list re-rendering every row on every keystroke elsewhere is the usual cause of a sluggish page.
- Use content-visibility: auto on long non-virtualised lists to skip off-screen layout for free.
Common mistakes
- Divs instead of ul and li, so the item count is never announced.
- list-style: none with no role="list", which silently removes semantics in Safari.
- Building a table out of a list, with columns the reader has to align by eye.
- Metadata at the same weight as the title, so the row has no primary label.
- A nested action that does not stop propagation, navigating away when the user aimed at the menu.
- The title pushing the status off the row.
- Three-line rows that should have been Cards.
- A silent empty state.
Real-world recommendations
- Lists are the most-used container in most products and the least designed. The two-step from title to metadata does more for scannability than any amount of spacing work.
- Users click rows, not labels. If the row navigates, make the row the target — the alternative is a permanent stream of "the link doesn’t work" reports.
- The moment a designer asks for a fourth column in a list, the answer is a table. Resisting that once saves rebuilding it later.
- On mobile, swipe actions are expected on list rows in native apps and rarely discoverable on the web. An overflow menu is less elegant and far more likely to be found.