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.

Three rows. The dividers are between them, not around them.
First
Second
Third
<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-main is flex: 1 1 auto with min-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-title and .list-sub are the two lines inside it, separated by a one-pixel gap rather than a margin.
  • .list-trail is flex: 0 0 auto and faint — a timestamp, a count, a chevron. It never shrinks.
Avatar, main, trail — the shape most lists want
AC
Ada Chen ada@example.com
Owner
MS
Marco Silva marco@example.com
Editor
PR
Priya Raman priya@example.com
Viewer
<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.

The trail stays put; the title clips
a-very-long-export-filename-2026-03-11.csv generated by the nightly job, 04:12 UTC
2.4 MB
<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>

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

Hover a row: the chevron steps forward
<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.

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

Today
Export finished
09:12
Invoice paid
08:40
Yesterday
Member invited
17:03
<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.

Resize the list. The window stays where it is.
Scales with the list Not with the window
<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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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

  • .list is not a list. It is a flex column of <div>s with no role, 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-row resets color and text-decoration rather than expecting a wrapper.
  • Hover has no focus counterpart. .list-row:hover changes the background; :focus-visible gets 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-visible yourself.
  • 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-title instead — the same pattern as card.
  • .list-trail is faint by design. --text-faint against --surface is 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-muted and clears the ratio.
  • Dragging is pointer-only. See Reordering. This is a documented gap, not an oversight.
  • .list-virtual and find-in-page. content-visibility: auto keeps 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.

The chevron is mirrored by .mirror-rtl and steps the other way
<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

  • .list versus .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 .menu with .menu-item. It carries the roving focus, the role="menu" semantics and the dismiss behaviour that a list of links does not.
  • Not for navigation. Use .sidebar-link or .nav-link, which have the current-page state. aria-current on a .list-row is styled by nothing.
  • Not for a stack of cards. If each item needs an image, a paragraph and its own actions, it is a .card in a .grid. A .list-row is 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 .switch looks 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.