List
.list is a bordered, clipped flex column; .list-row is one
row inside it. The row is the component that matters: a flex line with a
.75rem gap and
min-block-size: var(--tap), so every row clears the
44px touch target whether or not it holds
anything tall.
When to use it
Use a list when the reader reads one row at a time — people, files, notifications,
settings. Each row is a self-contained thing with a name and maybe a detail. If the
reader needs to compare a value down the column, that is a
.table instead.
Dividers come from .list-row + .list-row, so the first row has no top
border and the list needs no :last-child exception. Rows in the markup
are all that is needed; nothing has to be wrapped.
<div class="list" style="max-inline-size:28rem">
<div class="list-row">First</div>
<div class="list-row">Second</div>
<div class="list-row">Third</div>
</div>
<div class="list" style="max-inline-size:28rem">
<div class="list-row">First</div>
<div class="list-row">Second</div>
<div class="list-row">Third</div>
</div>
Main, title, sub, trail
A row is usually three parts: something at the start, the text in the middle, something at the end.
-
.list-mainisflex: 1 1 autowithmin-inline-size: 0, so it takes the free space and is allowed to shrink. That second half is what stops a long email address from pushing the trailing badge off the end of the row. -
.list-titleand.list-subare the two lines inside it, separated by a one-pixel gap rather than a margin. -
.list-trailisflex: 0 0 autoand faint — a timestamp, a count, a chevron. It never shrinks.
<div class="list" style="max-inline-size:30rem">
<div class="list-row">
<span class="avatar avatar-sm">AC</span>
<div class="list-main">
<span class="list-title">Ada Chen</span>
<span class="list-sub">ada@example.com</span>
</div>
<span class="list-trail">Owner</span>
</div>
<div class="list-row">
<span class="avatar avatar-sm">MS</span>
<div class="list-main">
<span class="list-title">Marco Silva</span>
<span class="list-sub">marco@example.com</span>
</div>
<span class="list-trail">Editor</span>
</div>
<div class="list-row">
<span class="avatar avatar-sm">PR</span>
<div class="list-main">
<span class="list-title">Priya Raman</span>
<span class="list-sub">priya@example.com</span>
</div>
<span class="list-trail">Viewer</span>
</div>
</div>
<div class="list" style="max-inline-size:30rem">
<div class="list-row">
<span class="avatar avatar-sm">AC</span>
<div class="list-main">
<span class="list-title">Ada Chen</span>
<span class="list-sub">ada@example.com</span>
</div>
<span class="list-trail">Owner</span>
</div>
<div class="list-row">
<span class="avatar avatar-sm">MS</span>
<div class="list-main">
<span class="list-title">Marco Silva</span>
<span class="list-sub">marco@example.com</span>
</div>
<span class="list-trail">Editor</span>
</div>
<div class="list-row">
<span class="avatar avatar-sm">PR</span>
<div class="list-main">
<span class="list-title">Priya Raman</span>
<span class="list-sub">priya@example.com</span>
</div>
<span class="list-trail">Viewer</span>
</div>
</div>
Why min-inline-size: 0 is there
A flex item's minimum size is its content by default, so a long unbroken string
refuses to shrink and pushes everything after it out of the row.
.list-main sets min-inline-size: 0 to allow the shrink;
add .truncate to the line you want clipped.
<div class="list" style="max-inline-size:22rem">
<div class="list-row">
<div class="list-main">
<span class="list-title truncate">a-very-long-export-filename-2026-03-11.csv</span>
<span class="list-sub truncate">generated by the nightly job, 04:12 UTC</span>
</div>
<span class="list-trail">2.4 MB</span>
</div>
</div>
<div class="list" style="max-inline-size:22rem">
<div class="list-row">
<div class="list-main">
<span class="list-title truncate">a-very-long-export-filename-2026-03-11.csv</span>
<span class="list-sub truncate">generated by the nightly job, 04:12 UTC</span>
</div>
<span class="list-trail">2.4 MB</span>
</div>
</div>
Rows that are links
.list-row sets color: inherit and
text-decoration: none, so it can be an <a> directly
with no reset of its own. That is the right way to build a navigable list: one
element, one tab stop, a real destination, and the whole row as the target.
<div class="list" style="max-inline-size:28rem">
<a class="list-row" href="#rows-as-links">
<div class="list-main"><span class="list-title">Billing</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
<a class="list-row" href="#rows-as-links">
<div class="list-main"><span class="list-title">Members</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
<div class="list" style="max-inline-size:28rem">
<a class="list-row" href="#rows-as-links">
<div class="list-main"><span class="list-title">Billing</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
<a class="list-row" href="#rows-as-links">
<div class="list-main"><span class="list-title">Members</span></div>
<svg class="icon icon-follow list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
The chevron moves because src/16-motion.css translates
.icon-follow by three pixels on .list-row:hover. It is
declared inside @media (prefers-reduced-motion: no-preference), so a
reader who has asked for less motion never gets the rule at all.
Group headers
.list-header is a small uppercase band on
--surface-2, for breaking a long list into sections. It is a row in the
same flex column, so it needs no wrapper and it participates in the same dividers.
<div class="list" style="max-inline-size:28rem">
<div class="list-header">Today</div>
<div class="list-row"><div class="list-main"><span class="list-title">Export finished</span></div><span class="list-trail">09:12</span></div>
<div class="list-row"><div class="list-main"><span class="list-title">Invoice paid</span></div><span class="list-trail">08:40</span></div>
<div class="list-header">Yesterday</div>
<div class="list-row"><div class="list-main"><span class="list-title">Member invited</span></div><span class="list-trail">17:03</span></div>
</div>
<div class="list" style="max-inline-size:28rem">
<div class="list-header">Today</div>
<div class="list-row"><div class="list-main"><span class="list-title">Export finished</span></div><span class="list-trail">09:12</span></div>
<div class="list-row"><div class="list-main"><span class="list-title">Invoice paid</span></div><span class="list-trail">08:40</span></div>
<div class="list-header">Yesterday</div>
<div class="list-row"><div class="list-main"><span class="list-title">Member invited</span></div><span class="list-trail">17:03</span></div>
</div>
.list-header is styling, not structure. A screen reader hears the word
"Today" as ordinary text with no indication that it heads the rows beneath. If the
grouping is meaningful, use a real heading inside it or split into separate lists
with headings between them.
Responds to its own width
.list.cq makes a list a query container, so rows inside it can respond
to the list's own width rather than the viewport's. That matters because a list is
the component most likely to appear in a narrow sidebar and a wide main
column on the same page — a viewport breakpoint gets one of those two wrong.
<div class="list cq" style="resize:horizontal;overflow:auto;min-inline-size:14rem;inline-size:24rem;max-inline-size:100%">
<div class="list-row">
<div class="list-main">
<span class="list-title text-cq">Scales with the list</span>
<span class="list-sub">Not with the window</span>
</div>
</div>
</div>
<div class="list cq" style="resize:horizontal;overflow:auto;min-inline-size:14rem;inline-size:24rem;max-inline-size:100%">
<div class="list-row">
<div class="list-main">
<span class="list-title text-cq">Scales with the list</span>
<span class="list-sub">Not with the window</span>
</div>
</div>
</div>
Long lists
.list-virtual puts content-visibility: auto and
contain-intrinsic-size: auto 56px on every row. The browser skips
layout, style and paint for rows that are offscreen, and uses 56px as the placeholder
height so the scrollbar stays honest. It is one class, no JavaScript, and no
windowing library.
The 56px is a guess about your rows. If yours are taller the scrollbar will jump
slightly as rows are rendered; override
contain-intrinsic-size in app.components with your real
height.
<div class="list list-virtual">
<!-- two thousand .list-row children -->
</div>
src/26-perf.css disables it for print — offscreen rows have to be
rendered when the whole list goes on paper. .list also gets
contain: layout style there, so one list relaying out cannot reflow the
rest of the page.
Reordering
.reorder on the list makes rows unselectable, and
.is-lifted is what a row looks like while it is being dragged: a deep
shadow, an opaque background and a radius, so it reads as lifted off the stack.
Deck styles the states; deck-adapters.js drives them with SortableJS
when it is present and the native drag-and-drop API when it is not.
.is-lifted is set by JavaScript at runtime and is
internal — style it if you like, but do not write it in markup.
Drag-and-drop reordering has no keyboard equivalent in Deck. A list that can only be reordered by dragging cannot be reordered by a keyboard-only user at all. Provide move-up and move-down buttons as well, or make the order editable some other way.
Classes
Generated from the stylesheet source by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.list |
deck.components |
A bordered, clipped column of rows. Use it when the reader reads one row at a time; if they compare values down a column, that is a table. | 07-components.css:300 |
.list-header |
deck.components |
A group heading inside a list. Styling only: a screen reader hears ordinary text, so if the grouping is meaningful use a real heading. | 07-components.css:337 |
.list-main |
deck.components |
No declarations. | 07-components.css:326 |
.list-row |
deck.components |
One row. min-block-size: var(--tap) puts every row at the touch target, and the colour and text-decoration resets mean the row can be an <a> directly — one element, one tab stop, the whole row clickable. | 07-components.css:311 |
.list-sub |
deck.components |
No declarations. | 07-components.css:330 |
.list-title |
deck.components |
No declarations. | 07-components.css:328 |
.list-trail |
deck.components |
No declarations. | 07-components.css:334 |
.list-virtual |
deck.components |
No declarations. | 26-perf.css:34 |
.is-lifted |
deck.components |
No declarations. | 26-perf.css:223 |
Tokens
--tap is the one worth noting: it is the row's
min-block-size, which is why a list of one-word rows is still
comfortable on a phone.
| Token | Live | Declared value |
|---|---|---|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--r-md |
12px |
|
--r-sm |
7px |
|
--tap |
44px |
|
--space-3 |
.75rem |
|
--space-4 |
1rem |
|
--text-sm |
.8125rem |
|
--text-xs |
.75rem |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--shadow-3 |
0 4px 8px oklch(20% .03 var(--shadow-color) / .06), 0 12px 28px oklch(20% .03 var(--shadow-color) / .10) |
|
--dur-1 |
110ms |
Accessibility
-
.listis not a list. It is a flex column of<div>s with norole, so a screen reader announces no item count and no "list of 12". If the count matters, use<ul>and<li class="list-row">— the styles work unchanged — or put the count in a heading above. -
Touch target is handled.
min-block-size: var(--tap)puts every row at 44px or taller, so a row that is a link is a legal target without any extra padding. -
A row that is an
<a>is one tab stop with a real name — the row's text. That is the accessible way to build a navigable list, and it is why.list-rowresetscolorandtext-decorationrather than expecting a wrapper. -
Hover has no focus counterpart.
.list-row:hoverchanges the background;:focus-visiblegets the global focus ring but not the background. A keyboard user sees the ring, which is enough — but if you rely on the background to show position, add:focus-visibleyourself. -
Buttons inside a row that is a link do not work. Nesting
interactive elements inside an
<a class="list-row">is invalid HTML and the inner control will not reliably receive clicks. Make the row a<div>and put a stretched link in.list-titleinstead — the same pattern as card. -
.list-trailis faint by design.--text-faintagainst--surfaceis below 4.5:1. It is intended for a repeated timestamp or a chevron, not for information the reader needs. Anything load-bearing belongs in.list-sub, which is--text-mutedand clears the ratio. - Dragging is pointer-only. See Reordering. This is a documented gap, not an oversight.
-
.list-virtualand find-in-page.content-visibility: autokeeps offscreen content searchable by the browser, so ⌘F still works. It does change the scroll height as rows render, which can move the page under a reader.
Right to left
Rows are flex lines with gap and logical padding, so the avatar moves to
the right and the trail to the left under dir="rtl" with no extra
rules. Dividers are border-block-start, which is unaffected by
direction. The one directional detail is the hover chevron, which
src/19-logical.css flips so it steps toward the text end rather than
always to the right.
<div dir="rtl" class="list" style="max-inline-size:26rem">
<a class="list-row" href="#rtl">
<span class="avatar avatar-sm">ا</span>
<div class="list-main">
<span class="list-title">الفواتير</span>
<span class="list-sub">ثلاث فواتير غير مدفوعة</span>
</div>
<svg class="icon icon-follow mirror-rtl list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
<div dir="rtl" class="list" style="max-inline-size:26rem">
<a class="list-row" href="#rtl">
<span class="avatar avatar-sm">ا</span>
<div class="list-main">
<span class="list-title">الفواتير</span>
<span class="list-sub">ثلاث فواتير غير مدفوعة</span>
</div>
<svg class="icon icon-follow mirror-rtl list-trail" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#chevron-right"></use></svg>
</a>
</div>
Reduced motion
.list-row transitions background-color over
110ms. Under
prefers-reduced-motion: reduce the global reset collapses it to
.01ms, so the hover state still happens instantly.
The stepping chevron is declared only inside
@media (prefers-reduced-motion: no-preference), so it is never declared
rather than declared and cancelled. The lifted-row shadow is a static state, not an
animation, and is unaffected.
Printing
src/99-print.css gives .list the same surface treatment as
a card — a #bbb hairline, no radius, white background,
break-inside: avoid — and keeps .list-header's grey fill
with print-color-adjust: exact so group headers still read as headers.
src/26-perf.css turns content-visibility off for print so
no rows are skipped.
Overriding it
@layer app.components {
/* Taller rows, and tell the virtual list about it */
.list-row { min-block-size: 4rem; }
.list-virtual > .list-row { contain-intrinsic-size: auto 64px; }
/* A focus background to match the hover one */
.list-row:focus-visible { background: var(--surface-hover); }
}
If you change the row height, change contain-intrinsic-size to match.
Leaving them out of step is what makes a virtual list's scrollbar jump.
When not to use it
-
.listversus.table. If values line up in columns and the reader compares them, use a table. A list of rows with four spans each is a table that has lost its column alignment. -
Not for a menu. Use
.menuwith.menu-item. It carries the roving focus, therole="menu"semantics and the dismiss behaviour that a list of links does not. -
Not for navigation. Use
.sidebar-linkor.nav-link, which have the current-page state.aria-currenton a.list-rowis styled by nothing. -
Not for a stack of cards. If each item needs an image, a
paragraph and its own actions, it is a
.cardin a.grid. A.list-rowis a single line with a start and an end; forcing a block of content into it fights the flex row. -
Not for form controls. A list of rows each holding a
.switchlooks right and behaves badly — the row's hover suggests the row is clickable when only the switch is. Use.check-card, which makes the whole block a label.