List
A general-purpose content list — a stack of peer item rows (leading media · title/description · trailing meta/actions) with three selection models.
@garn/ui/listOn 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.
npx garn-ui add list- 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
| Selector | State | Description |
|---|---|---|
[data-selected] | selected | A selected row — a tinted fill; in grid mode the row also carries aria-selected. |
[data-interactive] | interactive | A navigational (href / onActivate) row — hover/press affordance; the title carries the row-spanning overlay. |
[data-disabled] | disabled | An inert, non-selectable row (skipped by select-all and range). |
[data-expanded] | expanded | An expandable row with its disclosure open — the chevron rotates and List.ItemDetail is revealed. |
[data-unread] | unread | An unread row — a bolder title and a leading dot. |
[data-status] | status | Per-row liveness — loading / saving show a trailing Spinner + aria-busy; error implies the danger tone. The tone accent is a short, rounded pill on the row's inline-start (absolutely positioned so it never shifts the leading icon). |
| Property | Token | Tier |
|---|---|---|
rowGap | --garn-gap-stack | semantic · air |
rowHeight | --garn-control-h-md | component · identity |
surfaceRadius | --garn-radius | semantic |
toneAccent | --garn-badge-neutral-boldbrandable | component |
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
- 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; usedensityfor 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 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
valueon 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
Give the List an accessible name via `label`, `aria-label`, or `aria-labelledby`.
when a List has no `label`, `aria-label`, or `aria-labelledby`
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`
- 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).