Dev view
List Item
A single row in a list — settings rows, contact lists, file browsers, transactions, message threads, search results. Distinct from Card (standalone object with no implicit siblings) by living inside an explicit list (`<ul>`, `<ol>`, or `<dl>`) and cooperating with sibling rows for keyboard navigation, selection semantics, and visual rhythm. Distinct from Tile (image-led, 2D grid) by being one-dimensional and text/metadata-led.
When to use
Use
For a one-dimensional list of related rows where each row carries primary identity plus optional supporting metadata — settings rows, contact lists, file browsers, message threads, search results, notification feeds. Rows cooperate via list semantics (sibling-aware keyboard navigation, selection, current-state).
Avoid
For standalone content surfaces with hierarchical composition — that is `Card`. For image-led 2D grid items — that is `Tile`. For table-like data with multiple sortable columns — that is `Table`. For floating selection from a fixed list — that is `Select`. For navigation between top-level pages with icons and sections — that is `SidebarNav`.
Versus related
- card
`Card` is a standalone object with no implicit siblings; `ListItem` lives inside an explicit list and cooperates with siblings. Visual treatment differs (Card is elevated/outlined surface; ListItem is dense row separated by dividers or spacing). Decision test: is each item independent (Card) or part of a sibling-aware collection (ListItem)?
- tile
`Tile` is image-led and arranges in a 2D grid; `ListItem` is text/metadata-led and arranges in a 1D list. ListItem suits dense metadata layouts; Tile suits image-led visual layouts.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
root | root | listitem |
leading-icon | leading-icon | presentational-or-img |
avatar | avatar | img |
primary | primary | text |
secondary | secondary | text |
trailing-icon | trailing-icon | presentational-or-status |
badge | badge | presentational-or-status |
action | action | button |
Variants, properties, states
Variants
Structurally different versions of the component.
standardtwo-linethree-line Properties
The same component, parameterised.
| Property | Type |
|---|---|
interactive | boolean |
selectable | boolean |
swipeable | boolean |
density | comfortable | compact |
leadingType | none | icon | avatar |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactivedisabled |
data | idleselectedcurrent |
State transitions
| From | To | Trigger |
|---|---|---|
idle | selected | User toggles selection — clicking the leading checkbox (multi-select) or activating the whole row in single-select mode (`role="listbox"` parent). `aria-selected="true"` set; visual treatment changes. |
selected | idle | User toggles selection again or clears via programmatic update. |
idle | current | User navigates to the route owned by this row (for ListItem-as-nav usage). `aria-current="page"` set; visual treatment changes. Other rows in the same list lose their current state. |
current | idle | User navigates away. The row loses `aria-current="page"`. |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-list-item>` host wrapping `<li>`; named slots for leading-icon, avatar, primary, secondary, trailing-icon, badge, action; light DOM for content | attributes (`variant="two-line"`, `interactive`, `selectable`, `density="comfortable"`, `leading-type="avatar"`); `data-state="idle|selected|current"` for CSS |
| React | Compound components (`<List>`, `<ListItem>` with subcomponents like `<ListItem.Avatar>`, `<ListItem.Primary>`, `<ListItem.Secondary>`); React Aria `useListBox` plus `useOption` for selectable variant; integrates with virtualization libraries (react-window) for long lists | props with class-variance-authority for variant / density; controlled or uncontrolled selection state; `aria-current` on items when used as nav |
| Angular (signals) | `<ui-list>` plus `<ui-list-item>` components with content projection; Angular CDK Listbox (`cdk-listbox`) for the selectable variant | input<'standard' | 'two-line' | 'three-line'>(); `[interactive]`, `[selectable]`, `[swipeable]` host bindings; `[(selected)]` two-way binding |
| Vue | Custom `<List>` plus `<ListItem>` SFCs with named slots; integrates with manual list keyboard composition | defineProps with literal-union types; `:variant`, `:interactive`, `:selectable` props |
Events
clickActivateselectedChangeswipeAction
Form integration
- name attribute
- For selectable list-item usage in forms, the leading checkbox carries the form `name` and `value`. Common pattern: a list of items rendered as ListItem with selectable variant, wrapped in a `<fieldset>` with `<legend>` describing the multi-select intent. Each selected item contributes a FormData entry under the fieldset's name.
- FormData serialization
- Multi-select list produces one FormData entry per selected item (matching `<input type="checkbox">` behaviour). Canonical: `name=item-ids[]&value=<itemId>` per selected row. For single-select listbox-pattern, the form value is the single selected itemId; the list's underlying control is a hidden input synced with selection state.
- form.reset()
- `form.reset()` restores items to their `defaultChecked` state. Visual selected state clears; `aria-selected` reverts on each row. Framework state held outside the DOM needs an `onReset` listener.
- HTML5 validation
- For required selectable lists ("select at least one"), the first checkbox in the list carries `setCustomValidity('Select at least one item')` plus `aria-invalid` until selection occurs. For single- select listbox with required, the underlying hidden input drives validation.
Performance thresholds
virtualisationThresholdrow-count≥100rowsAbove ~100 rows in a single list, render virtualisation becomes necessary. Keeping all rows in the DOM at once degrades scroll performance and SR navigation. Mature libraries (react-window, react-virtualized, @tanstack/react-virtual) handle this; canonical reference documents the threshold.
itemPaintBudgetper-row-paint-cost≥1msEach row's paint cost should stay under 1ms to keep the total list paint within the 16ms 60fps frame budget at typical viewport-row-counts (10–30 visible rows). Rows with rich content (avatars, badges, multi-line text) may exceed this; profile and simplify if list paint dominates initial render.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
root | Always wrap inside a real list (`<ul>` / `<ol>`) for the canonical list semantic. For interactive variants, the row may be wrapped in a `<button>` or `<a>` (whole- row clickable). For selectable variants, the row carries `aria-selected` and lives inside `role="listbox"` (single-select) or has a leading checkbox (multi-select). For current-page variants (in navigation lists), the row carries `aria-current`. | |
leading-icon | Decorative when paired with a primary text label (`aria-hidden="true"`). For information-carrying icons (a verified-account checkmark, a syncing-status indicator), surface the meaning via visually-hidden text composed into the row's accessible name. | |
avatar | For person avatars, alt text is the person's name (often redundant with the primary label — set `alt=""` then, with the avatar as decorative). For status-indicating avatars (presence dots overlaid on the avatar), surface status via visually-hidden text in the row's accessible name ("Alice (online)"). | |
primary | Plain text. The row's primary identifier for SR users. Avoid visually-hidden modifiers in the primary slot — the visible text and the accessible name should match. | |
secondary | Plain text. SR users hear primary then secondary in DOM order when navigating into the row. Avoid embedding inline interactive elements (links, buttons) in secondary text — they break the row's whole-row-clickable contract; put them in the action slot instead. | |
trailing-icon | Decorative when communicating affordance (chevron signalling "tap for more"); status-bearing when carrying meaning not in the primary text (a "syncing" indicator). For status, surface via visually-hidden text in the row's accessible name. | |
badge | For numeric badges, append the count to the row's accessible name via visually-hidden text ("Inbox, 3 unread"). Decorative-only badges (a pulse for "new") use `aria-hidden="true"` with the meaning conveyed in primary text or via composed accessible name. | |
action | Real `<button>` with accessible name. For whole-row-clickable rows, the action button's click must `event.stopPropagation()` to prevent activating the parent row. The action button is reachable via Tab; for selectable list rows, ArrowKeys do NOT navigate to the action — only Tab does. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | For interactive list, Tab enters the first item (roving tabindex when in selectable / current-page mode). Subsequent Tab moves to the action button on the focused row (if present), then to the next list region or the next document focusable. |
ArrowDown / ArrowUp (focus on a row) | For selectable list (`role="listbox"`) or current- page nav list, moves focus to the next / previous row. Wraps at boundaries per APG. Tab does NOT move between rows in this mode — ArrowKeys do. |
Home / End (focus on a row) | Moves focus to the first / last row. |
Enter (focus on interactive row) | Activates the row — fires the click handler. For nav-style rows, navigates to the route. |
Space (focus on selectable row) | Toggles the row's selected state. `aria-selected` flips; visible checkbox state flips. |
Tab from row to action button | Moves focus to the row's action button without activating the row. Action button has its own Tab stop separate from the row's tabindex. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Focus enters interactive row | SR announces "<primary text>, button" (or "link" for nav-style rows). Two-line variant adds the secondary text after the primary in DOM order. Avatar / leading-icon are decoratively skipped if aria-hidden. |
| Focus enters selectable row | SR announces "<primary>, list item, X of N, selected" (or "not selected"). Position cue X-of-N comes from the listbox's implicit collection semantics or from explicit `aria-posinset` / `aria-setsize`. |
| Focus enters current-page row | SR announces "<primary>, current page, link". Driven by `aria-current="page"` on the row's anchor. |
| Selection toggles (Space pressed) | SR announces the new state ("selected" or "not selected"). Some SR / screen-reader-mode combinations announce only on focus change, not on Space press; the visual state change is the complement. |
axe-core rules to assert
aria-allowed-rolearia-required-attraria-required-childrenaria-required-parentaria-valid-attr-valuecolor-contrastlink-namebutton-nameimage-alt
Common mistakes
#listitem-not-in-list
ListItem used standalone outside a list
The component is rendered without a `<ul>` / `<ol>` parent. SR users hear the content but lose the list structure — no "list of N items" announcement, no list-item position cue. Standalone usage breaks the semantic contract.
Always render ListItem inside a real list. For single-row use cases (just one row), reconsider whether the canonical pattern is ListItem or some other component (a section header, a Card, a settings row grouped as part of a fieldset).
#listitem-action-no-stop-propagation
Action button click activates parent row
The whole row is clickable; the action button is nested inside. Action click bubbles to the row's handler; users invoking the action get the row's activation as a side effect.
Action button's onClick calls `event.stopPropagation()` to prevent bubbling. Document the pattern in the action slot's a11y hint and in the canonical reference.
#listitem-no-aria-selected
Selectable rows missing `aria-selected`
Visual selected state is shown but `aria-selected` is missing. SR users hear nothing about selection state. Or `aria-selected` is on the wrong element (the checkbox, not the row).
For listbox-pattern selection, `aria-selected` is on the row (`<li>`). For multi-select with checkbox, `aria-checked` is on the checkbox AND `aria-selected` is on the row — both communicate selection redundantly. Pair with visible visual treatment.
#listitem-swipe-no-keyboard
Mobile swipe-to-delete with no keyboard equivalent
Swipeable rows expose actions on mobile via swipe gestures. Keyboard users on desktop cannot perform the swipe-revealed actions; they remain hidden.
Every swipe-revealed action also reachable via Tab and Enter. Common pattern: the action button is always in the DOM but visually hidden (or visible on hover) on desktop; swipe reveals the same buttons on mobile. Both input modalities have access.
#listitem-no-roving-tabindex
Selectable list with every row tabindex="0"
Tab cycles through every row in a selectable list (could be 100+ rows). APG listbox / grid pattern uses roving tabindex — only one row is in the tab order, ArrowKeys navigate within.
Roving tabindex per APG listbox or grid pattern. Only the currently-focused row has `tabindex="0"`; others have `tabindex="-1"`. ArrowKeys move focus within; Tab moves focus out of the list to the next document focusable.
#listitem-secondary-text-as-link
Inline links embedded in secondary text
Secondary text contains "View · Edit · Delete" as inline links. The whole-row click handler conflicts with link clicks; or the action slot becomes redundant; or developers omit the action slot entirely and rely on embedded links (breaking keyboard reachability).
Inline interactions move to the action slot as a single action button (typically a more-options MenuButton). Secondary text remains plain prose. Document the pattern; reject inline-link mocks at design review.
Figma↔Code mismatches
- 01 Figma
ListItem drawn as a Card-like surface
CodeA `<li>` row inside a list, not a standalone Card surface
ConsequenceDesigners may compose ListItem mocks with Card-like treatment (rounded corners, shadow, elevated surface). Implementations following the design ship rows that look like cards but live inside lists with dense vertical rhythm — visual hierarchy is confused, the list feels noisy, and the canonical Card-vs-ListItem distinction is lost.
CorrectListItem is a row in a list — visual treatment is restrained, separation between rows is via dividers or negative space, not via independent card-like surfaces. For Card-like surfaces in a layout, use Card or Tile directly; do not style ListItem as Card.
- 02 Figma
Whole-row click affordance + nested action button
CodeRow click + action button click both work, with action's `event.stopPropagation`
ConsequenceDesigners compose mocks with both a whole-row click target AND an inline action button. Implementations may forget the `stopPropagation` — clicking the action activates both the action AND the row's click handler; users get unexpected double-effects (delete a message AND navigate to it). Or developers may remove the action button entirely to avoid the conflict.
CorrectBoth whole-row click and action button are valid simultaneously — the action's onClick must call `event.stopPropagation()` to prevent row activation. Document the canonical pattern; the action is a separate target in the row's tab order, distinct from the row activation.
- 03 Figma
Selected state shown only by background colour
CodeSelected state shown by background + leading checkbox checked + `aria-selected="true"`
ConsequenceDesigners use a single visual change for selection. Implementations following the design ship colour-only selection, fail WCAG 1.4.1, and SR users hear no selection cue. The leading checkbox is omitted; users may not realise rows are selectable.
CorrectTriple-layer selection: visual (background + accent strip), accessible state (`aria-selected="true"` on row, `aria-checked` on checkbox), and visible checkbox (the canonical multi-select affordance for ListItem). For single-select listbox-pattern rows, the visible checkbox may be omitted in favour of visual-state-only-plus-aria-selected.
- 04 Figma
Inline links in secondary text
CodeInline interactive elements move to the action slot or break the row activation
ConsequenceDesigners compose secondary text with embedded inline links ("View profile, Edit, Delete"). Implementations following the design either ship the embedded links (which break whole-row click — clicking a link does not activate the row) or hoist them out (the design and production diverge).
CorrectInline interactive elements in row content are an antipattern. Move them to the action slot as a single action button (typically more-options menu) or split the row into multiple list items. Document this as mistake `listitem-secondary-as-link`.