Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

Deployment
dpl_7Hq3nR8vTx
Region
Europe (London) · eu-west-2
Duration
42 seconds
Triggered by
Ada Lovelace
Commit
4021ab9

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.

One lineScanning
  • api-gatewayLive
  • billing-workerLive
  • search-indexerFailed
  • web-frontendBuilding
Two linesDistinguishing
  • api-gatewayDeployed 4 minutes agoLive
  • billing-workerDeployed 2 hours agoLive
  • search-indexerFailed 20 minutes agoFailed
  • web-frontendBuilding…Building

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.

ListOne label, one status
  • api-gatewayLive
  • billing-workerLive
  • search-indexerFailed
  • web-frontendBuilding
Faux tableTwo aligned columns, no headers
api-gateway42seu-west-2Live
billing-worker42seu-west-2Live
search-indexer42seu-west-2Failed
web-frontend42seu-west-2Building

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.

api-gateway
One line
api-gateway4 minutes ago
Two lines
api-gateway
Hover
api-gateway
Selected
ALAda Lovelace
With avatar
search-indexerFailed
With badge
Regioneu-west-2
Key–value
No deployments yet
Empty

Anatomy

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.

  1. 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.

  2. Horizontal padding14px

    Matches the container’s own padding, so a list inside a card does not look inset twice.

  3. Leading gap12px

    Fixed regardless of what is in the slot, so an icon row and an avatar row share a text edge.

  4. Title13px, --ds-fg

    The only full-contrast text in the row. Everything else is metadata and steps down.

  5. 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.

  6. TrailingRight-aligned, shrink-0

    A badge, a count, a chevron or an action. It never shrinks — the title truncates instead.

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-3Leading gap
--space-3-5Row horizontal padding

Radius

TokenValueUsed for
--radius-lgContainer corners

Typography

TokenValueUsed for
--text-labelRow titles

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingLabel gapTypeMin widthMax widthTouch targetWhen to use
Compact32px4px 12px—12px———Dense sets a user scans rather than reads — a file list, a log.
One line40px8px 14px—13px——44px on coarse pointersThe default for a scannable set.
Two lines52px10px 14px—————When the metadata is what distinguishes items.
With avatar56px—12px————A 32px avatar plus padding. Larger avatars belong in a Card.
Key column————7rem12rem—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.

Do

<ul><li>…</li></ul>
<dl> for term / definition pairs
Use real list markupA screen reader announces "list, 12 items" from ul and li alone. No amount of styling on a stack of divs provides that.
api-gateway
Make the whole row the target when it navigatesA link on the label alone leaves most of the row inert, and users click the row. The chevron is what says the whole thing is pressable.
api-gatewayDeployed 4 minutes ago
Step the metadata down twiceOne step in size and two in colour. Equal weight makes the row read as two titles, and the list stops being scannable.
ALAda Lovelace
GHGrace Hopper
Inset dividers to the textWith a leading avatar or icon, a full-width rule makes the list read as a table. Starting the rule at the title separates rows without implying columns.

Don't

api-gateway42seu-west-2Live
billing-worker42seu-west-2Live
search-indexer42seu-west-2Failed
Do not build a table out of a listOnce a second value aligns across rows, the reader is doing a table’s work with none of its sorting, alignment or headers.
api-gatewayDeployed 4 minutes agoRolled back from 4021 after the health check failed in eu-west-2.
Do not give a row three linesAt three lines the row stops being scannable and each item wants its own container. That container is a Card.
api-gateway
Do not nest an action inside a pressable row without stopping propagationThe user aims at the overflow menu, hits the row, and navigates away from what they were about to act on.
api-gateway-production-eu-west-2-primaryFailed
Do not let the title push the trailing content offThe status is often the reason the user is scanning. The title truncates; the trailing content never shrinks.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA2.5.8Target Size (Minimum)AA

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

TabStops on each interactive row, then on any secondary action inside it.
EnterActivates 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 / EndWith 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.
AttributeApplied toNotes
<ul> / <ol> / <li>The listThe item count comes from the markup. A stack of divs announces nothing.
<dl> / <dt> / <dd>Key–value pairsThe term/definition relationship is what a description list is for, and it is announced.
role="listbox" / "option"A selectable listOnly when the list holds a selection. A navigable list stays as links or buttons.
aria-current="page"The row matching the current viewDistinct from selection — it means "you are here".
aria-labelA secondary actionMust name the row: "More actions for api-gateway". Forty identical buttons otherwise.
list-style: noneStyled listsSafari removes list semantics when list-style is none. Add role="list" back explicitly.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
as'ul' | 'ol' | 'dl''ul'ol when the order is meaningful, dl for term/definition pairs. The element carries the semantics.
aria-labelstring—Names the set. A list of twelve rows with no label is twelve announcements with no context.
dividersbooleantrueInset to the text when there is a leading element.
density'compact' | 'default''default'Compact for sets that are scanned rather than read.

ListItem

PropTypeDefaultDescription
title*ReactNode—The only full-contrast text in the row.
descriptionReactNode—One line of metadata. Two lines means this should be a Card.
leadingReactNode—An icon or an avatar. The gap is fixed regardless of which.
trailingReactNode—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.
hrefstring—Makes the whole row a link. Prefer this over onClick when it navigates.

Notes

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.