Skip to content

List

A general-purpose content list — a stack of peer item rows (leading media · title/description · trailing meta/actions) with three selection models.

Stablev0.4.2added in v0.2.0@garn/ui/list
On this page

Default

Terse rows — leading icon + title + description + a trailing badge.

Appearances

plain · divided (inset) · card surfaces.

Selection Multiple

A keyboard grid — checkboxes, select-all, range, and a bulk-action bar.

Selection Single

A radiogroup — single-choice rows (a plan picker).

Grouped

List.Group section headers over nested lists.

Sizes

The size ladder — xs (condensed) · sm · md · lg.

Truncation

Title clamped to one line, description to N lines.

Loading Empty

List.Skeleton while loading; an Empty state via List.Empty.

Expandable

Expandable rows — a disclosure chevron reveals List.ItemDetail.

Status

Per-row status — unread, saving (spinner), error (retry), and a tone accent.

Keyboard Grid

A navigable link list (keyboardNavigation) — arrow keys + type-ahead.

Compose In

Compose-in — a right-click ContextMenu and an InlineEdit title.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add list
First time? Set up garn in your project
Registry
list
Deps
@radix-ui/react-slotlucide-react
Registry deps
utilsuse-controllable-stateuse-controllable-key-setuse-merged-refsortablecheckboxspinner

Import

import { List.Item, List.Leading, List.Content, List.Title, List.Description, List.Trailing, List.Actions, List.ItemDetail, List.Group, List.SelectionBar, List.Empty, List.Skeleton } from "@garn/ui/list";

Anatomy

The parts this component is built from — compose them to assemble it.

List.ItemRequired[data-slot="list-item"]

One row — a <li> (or a <div role=row> in the keyboard grid). Dual-mode: terse (leading/title/description/trailing/actions) or composed children. Carries value (selection key), disabled, href/onActivate (navigational overlay), expandable/detail, status/unread/tone. Reflects data-selected / data-disabled / data-interactive / data-expanded / data-status / data-tone.

List.Leading[data-slot="list-leading"]

The leading media gutter (icon / avatar / thumbnail). Shrinks its glyph at size xs.

List.Content[data-slot="list-content"]

The text column (title + description), min-w-0 so it truncates.

List.Title[data-slot="list-title"]

The primary text. asChild to become the row's link/button (the navigational overlay) or a heading; bolds when the row is unread.

List.Description[data-slot="list-description"]

The secondary text; truncate clamps to one line, lines to N (default from the List's descriptionLines).

List.Trailing[data-slot="list-trailing"]

Inert trailing metadata (timestamp / badge / count) — pushed to the row's end, not a tab stop.

List.Actions[data-slot="list-actions"]

Trailing focusable controls (buttons / overflow menu). Sit above the navigational overlay; in grid mode their tabindex roves with the active row.

List.ItemDetail[data-slot="list-item-detail"]

The disclosure content an expandable row reveals (arbitrary content or a nested <List>). Wired to the toggle via aria-controls; hidden while collapsed.

List.Group[data-slot="list-group"]

A section run — a header over a nested list of its items (a rowgroup in grid mode), optionally sticky; AT announces a sub-list under the heading.

List.SelectionBar[data-slot="list-selection-bar"]

The bulk-action bar — a tri-state select-all control, the selected count (announced via a polite live region), a clear button, and a slot for bulk actions. Reads the selection from context.

List.Empty[data-slot="list-empty"]

An empty-state slot — drop a garn <Empty> inside; a full-width list row.

List.Skeleton[data-slot="list-skeleton"]

A loading preset — rows of a leading circle + title/description placeholders in the row's geometry, hidden from AT.

Props

The component's public props and their types.

appearance
Description
Surface treatment: bare rows, hairline dividers, or one bordered card.
Type
"card" | "divided" | "plain"
Default
"plain"
selectionMode
Description
Selection model. `single` → radiogroup; `multiple` → keyboard grid.
Type
"multiple" | "none" | "single"
Default
"none"
size
Description
Row height / type ladder — `xs` is a condensed, denser row (distinct from `density`).
Type
"lg" | "md" | "sm" | "xs"
Default
"md"
defaultSelectedKeys
Description
Uncontrolled initial selection.
Type
string[]
density
Description
Local density override; omit to inherit the ambient `[data-density]`.
Type
"compact" | "spacious"
descriptionLines
Description
Lines a clamped description shows before ellipsis.
Type
number
Default
2
disabledKeys
Description
Keys that cannot be selected (skipped by select-all and range).
Type
string[]
inset
Description
Whether dividers align past the leading gutter (only with `divided`/`card`).
Type
boolean
Default
false
itemValues
Description
The item keys in order, for reorderable lists whose rows are WRAPPED in another component (child introspection only sees direct `List.Item`s — without this a wrapper-composed list reorders against an empty order).
Type
string[]
keyboardNavigation
Description
Whether to opt a non-selectable list into the roving keyboard grid (e.g. a link list).
Type
boolean
Default
false
label
Description
Convenience accessible name for the list.
Type
React.ReactNode
onReorder
Description
Called with the next key order when a reorder drops. You reorder your data.
Type
(keys: string[]) => void
onSelectionChange
Description
Called with the next selection whenever it changes.
Type
(keys: string[]) => void
reorderable
Description
Whether to enable drag-reorder (each `List.Item` needs a `value`; add a `List.DragHandle`). Powered by the `useSortable` primitive — keyboard + pointer + announcements.
Type
boolean
Default
false
selectedKeys
Description
Controlled selected keys (each `List.Item` needs a `value`).
Type
string[]
truncate
Description
Whether to clamp titles to 1 line and descriptions to `descriptionLines` ambiently.
Type
boolean
Default
false

Plus 279 inherited native <ul> attributes.

List.Item

actions
Description
Terse mode: trailing focusable controls (kept above the row overlay).
Type
React.ReactNode
defaultExpanded
Description
Uncontrolled initial expanded state.
Type
boolean
description
Description
Terse mode: the secondary text.
Type
React.ReactNode
detail
Description
Terse mode: disclosure content revealed when the row is expanded.
Type
React.ReactNode
disabled
Description
Whether the row is inert and non-selectable.
Type
boolean
Default
false
expandable
Description
Show a disclosure toggle that reveals `detail` / `List.ItemDetail`.
Type
boolean
expanded
Description
Controlled expanded state (pair with `onExpandedChange`).
Type
boolean
href
Description
Make the whole row a navigational link (the overlay pattern).
Type
string
leading
Description
Terse mode: the leading media (icon / avatar / thumbnail).
Type
React.ReactNode
onActivate
Description
Make the whole row activate on click (rendered as a button overlay).
Type
(event: React.MouseEvent) => void
onExpandedChange
Description
Called when the disclosure opens or closes.
Type
(expanded: boolean) => void
status
Description
Per-row liveness: loading/saving show a spinner; error tints the row danger.
Type
"error" | "idle" | "loading" | "saving"
Default
"idle"
title
Description
Terse mode: the primary text (becomes the row's link when interactive).
Type
React.ReactNode
tone
Description
Status tone accent for the row (a leading bar).
Type
"brand" | "danger" | "info" | "neutral" | "success" | "warning"
trailing
Description
Terse mode: inert trailing metadata (timestamp / badge / count).
Type
React.ReactNode
unread
Description
Whether to emphasise the row as unread (bold title + a leading dot).
Type
boolean
Default
false
value
Description
Stable selection key — required for a row to participate in selection.
Type
string

Plus 277 inherited native HTML attributes.

List.Title

asChild
Description
Whether to render as the child element (e.g. a link, button, or heading), merging props.
Type
boolean
Default
false

Plus 280 inherited native HTML attributes.

List.Description

lines
Description
Clamp to N lines; defaults to the List's `descriptionLines` when ambient truncation is on.
Type
number
truncate
Description
Whether to clamp to a single line with an ellipsis.
Type
boolean

Plus 280 inherited native HTML attributes.

List.Group

label
Description
Section header text.
Type
React.ReactNode
sticky
Description
Whether to stick the header to the top of the scroll container while its run is visible.
Type
boolean
Default
false

Plus 281 inherited native HTML attributes.

List.SelectionBar

clearLabel
Description
Label for the clear-selection button.
Type
string
Default
"Clear"
renderCount
Description
Label template — `count` is the selected total; falls back to "N selected".
Type
(count: number) => React.ReactNode
selectAllLabel
Description
Label for the select-all control.
Type
string
Default
"Select all"

Plus 280 inherited native HTML attributes.

List.Skeleton

leading
Description
Whether to show a leading media placeholder circle.
Type
boolean
Default
true
lines
Description
Description placeholder lines per row.
Type
number
Default
1
rows
Description
How many placeholder rows to render.
Type
number
Default
3
size
Description
Row height / type ladder for the placeholder rows.
Type
ListSize
Default
"md"

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="list"][data-slot="list-label"][data-slot="list-row"][data-slot="list-selection"][data-slot="list-unread"][data-slot="list-disclosure"][data-slot="list-status"]

States

SelectorState
[data-selected]selected
[data-interactive]interactive
[data-disabled]disabled
[data-expanded]expanded
[data-unread]unread
[data-status]status
PropertyTokenTier
rowGap--garn-gap-stacksemantic · air
rowHeight--garn-control-h-mdcomponent · identity
surfaceRadius--garn-radiussemantic
toneAccent--garn-badge-neutral-boldbrandablecomponent

Private instance vars (never externalize): --list-inset

When to use

A vertical collection of peer item rows — settings rows, an inbox / notification feed, a file or resource list, search results, a member list. Use terse <List.Item> for the common row; compose the slots for rich rows; set selectionMode for selectable lists and href/onActivate for navigable ones.

Reach for something else when

  • A single entity's 'field: value' facts (use DataList)
  • a chronology of events (use Timeline)
  • a ranked top-N metric breakdown (use BarList)
  • rows and columns where alignment/comparison matters (use Table)
  • a hierarchical, multi-level tree (use Tree)
  • picking one option from a popover (use Select / Combobox / Command).

Overview

List is the plain content collection — a stack of peer rows, each a leading-media · title/description · trailing-meta/actions row. List.Item is one row (terse props or composed slots); reach for it for settings rows, inbox/notification feeds, file lists, search results, and member/resource lists. It scales from an inert list to navigational rows (the whole row is one link) to full multi-select (a keyboard grid with select-all, range, and a bulk-action bar) — chosen by selectionMode, not a pile of booleans. For a term→value detail block use DataList; for a chronology use Timeline; for aligned columns of cells use Table; for a hierarchical tree use Tree.

Guidelines

Pick the interaction model deliberately. Leave rows inert for a read-only list; set href/onActivate for a navigable list (the whole row becomes one target); set selectionMode for selection — single is a radiogroup (a form choice), multiple is a keyboard grid (bulk actions). Give every selectable row a stable value.

Use the axes, not ad-hoc styling. appearance (plain/divided/card) sets the surface; size sets the row identity (xs for dense inbox/log rows); density tightens ambient spacing. Split inert metadata (List.Trailing) from focusable controls (List.Actions) so the tab model stays honest.

Keep it accessible. Name the list; don't nest interactives inside a navigational row's overlay — put secondary actions in List.Actions (they escape it); convey status with text/tone plus the sr-only cues, never colour alone. Long lists reorder via reorderable and virtualize via a windowing-library recipe, not built-in props.

Best practices

Do
  • Give the list an accessible name (label / aria-label).
  • Give every selectable row a stable value; drive selection with selectedKeys / onSelectionChange (or defaultSelectedKeys).
  • Put inert meta in List.Trailing and focusable controls in List.Actions (they escape the navigational overlay).
  • Use size="xs" for a condensed, dense list; use density for ambient tightening — they're different axes.
  • Group long lists with List.Group; show List.Skeleton while loading and an <Empty> (via List.Empty) when cleared.
Don't
  • Don't build a navigational row by wrapping the whole <li> in an <a> — use List.Title asChild (the overlay pattern) so nested actions stay valid.
  • Don't forget value on selectable rows (they can't be selected or keyed without it).
  • Don't rely on a row's tone/colour alone for status — pair it with text and the sr-only cues.
  • Don't reach for List for a tree, a table, or a term:value block — use Tree / Table / DataList.

Content guidelines

  • Lead the row with the title (what it is); keep the description terse and let it truncate.
  • Keep trailing metadata short (a time, a badge, a count); move anything actionable into List.Actions.

Troubleshooting

Building a multi-select list and expecting <ul>/<li role=row> — or axe flags role=row on <li>.

Cause. A real ARIA grid can't be built from <ul>/<li> (ARIA forbids role=row on <li>).

Fix. Nothing to do — selectionMode="multiple"/keyboardNavigation auto-forks to div[role=grid] > div[role=row] > div[role=gridcell]; inert/single modes stay <ul>/<li>.

Rows can't be selected / selection does nothing.

Cause. Selectable rows need a stable value; without it a row can't be keyed (a dev warning fires).

Fix. Give every selectable <List.Item> a value, and drive state with selectedKeys / onSelectionChange (or defaultSelectedKeys).

Wrapping the whole row in an <a> to make it clickable — nested buttons break.

Cause. A link around the <li> nests the action buttons inside the anchor (invalid interactives).

Fix. Use href/onActivate (or List.Title asChild) — the title becomes the sole link with a row-spanning ::after overlay, and List.Actions escape it.

Reaching for density="compact" to get a smaller, denser list.

Cause. density only tightens ambient spacing; the row's identity is the size axis.

Fix. Use size="xs" for the condensed row look (shorter rows, xs type); density is an orthogonal ambient tweak.

Using List for a tree, a table, or a field:value block.

Cause. List is a flat collection of peer rows.

Fix. Use Tree for hierarchy, Table for aligned columns, DataList for a single entity's field:value facts.

Accessibility

Role
list
Focus
Inert/navigational rows aren't focusable — only the title link and List.Actions are. The keyboard grid puts a roving tabindex on the rows (active row = 0, others = -1) and roves the active row's in-row controls; the radiogroup roves its radios. Every focusable control keeps a 2px --garn-ring focus ring.

Accessibility requirements

warn

Give the List an accessible name via `label`, `aria-label`, or `aria-labelledby`.

when a List has no `label`, `aria-label`, or `aria-labelledby`

warn

Give every selectable List.Item a stable `value` so it can be selected and keyed.

when selectionMode is not `none` and a List.Item has no `value`

A1.3.1Info and RelationshipsA1.4.1Use of ColorA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • Presentational / navigational lists are a real <ul role=list> of <li role=listitem>; a navigational row uses the 'card-with-primary-action' overlay — List.Title becomes the sole <a>/<button> with an ::after spanning the row (one tab stop) while List.Actions sit above the overlay with their own tab stops (no nested interactives).
  • `selectionMode="multiple"` (and `keyboardNavigation`) is a real ARIA grid: a <div role=grid aria-multiselectable> of <div role=row aria-selected> rows, each wrapping a <div role=gridcell>. A grid CANNOT be built from <ul>/<li> because ARIA forbids role=row on <li>, so grid mode forks the element types.
  • The grid roves row focus with a roving tabindex (one row tabbable at a time): Arrow Up/Down, Home/End, PageUp/PageDown move focus; type-ahead jumps to a title; Space toggles the focused row; Shift+Arrow / Shift+Click extend a range; ⌘/Ctrl+A selects all; Escape clears; Enter activates a navigational row. In-row controls (checkbox, link, actions) rove with the active row so Tab steps through the active row only.
  • `selectionMode="single"` flips the <ul> to role=radiogroup, the <li> wrappers take role=presentation, and each row renders a roving role=radio (Arrow keys move + select). It is a single-choice form control, not a grid.
  • The bulk-action bar (List.SelectionBar) has a tri-state select-all Checkbox and announces the selected count via a polite live region; disabled keys are skipped by select-all and range.
  • An expandable row's disclosure is a real <button aria-expanded aria-controls>; the revealed List.ItemDetail is wired by aria-controls (no role=region — an unnamed region is an anti-pattern).
  • Per-row status: loading/saving set aria-busy and show a Spinner; the row tone is a decorative accent (error implies the danger tone) — never the sole signal. Give the list an accessible name via `label`, `aria-label`, or `aria-labelledby` (dev-warns if missing).