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

Highlight
Fig 1.1 · List Item · Bridge view
Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    ListItem drawn as a Card-like surface

    Code

    A `<li>` row inside a list, not a standalone Card surface

    Consequence

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

    Correct

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

  2. 02
    Figma

    Whole-row click affordance + nested action button

    Code

    Row click + action button click both work, with action's `event.stopPropagation`

    Consequence

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

    Correct

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

  3. 03
    Figma

    Selected state shown only by background colour

    Code

    Selected state shown by background + leading checkbox checked + `aria-selected="true"`

    Consequence

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

    Correct

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

  4. 04
    Figma

    Inline links in secondary text

    Code

    Inline interactive elements move to the action slot or break the row activation

    Consequence

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

    Correct

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

Both

Variants, properties, states

Variants

Structurally different versions of the component.

standardtwo-linethree-line

Properties

The same component, parameterised.

PropertyType
interactiveboolean
selectableboolean
swipeableboolean
densitycomfortable | compact
leadingTypenone | icon | avatar

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactivedisabled
data
idleselectedcurrent
Both

Figma ↔ Code property map

FigmaTypeCodeNotes
VariantVariantvariantMaps standard / two-line / three-line. Drives layout — single-line is height-collapsed, two-line and three-line stack secondary text below primary.
DensityVariantdensitycomfortable / compact.
Leading TypeVariantleadingTypenone / icon / avatar. Mutually exclusive — leading-icon and avatar share the same slot position.
InteractiveBooleaninteractiveWhole row is clickable. Wraps row content in `<button>` or `<a>` activator.
SelectableBooleanselectableWhole row is selectable; visible leading checkbox appears. Drives `aria-selected` and `aria-checked`.
SwipeableBooleanswipeableMobile swipe-to-reveal-actions enabled. Pairs with action slot for parity on desktop.
Has Trailing IconBooleantrailingIcon
Has BadgeBooleanbadge
Has ActionBooleanaction
PrimaryTextprimary
SecondaryTextsecondary
AvatarInstance Swapavatar
Leading IconInstance SwapleadingIcon
Both

State transitions

FromToTrigger
idleselectedUser 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.
selectedidleUser toggles selection again or clears via programmatic update.
idlecurrentUser 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.
currentidleUser navigates away. The row loses `aria-current="page"`.
Designer

Figma anatomy

Slot Figma type Hint
root instance List row component instance with leading + primary + trailing layout
leading-icon instance Icon component instance; visibility per "has icon" property
avatar instance Avatar component instance with image, initials, or icon fallback
primary text Primary text style; truncates with ellipsis when single-line variant overflows
secondary text Secondary text style; muted; visibility bound to two-line / three-line variant
trailing-icon instance Icon component instance; visibility per "has trailing icon" property
badge instance Badge component instance; visibility bound to "has badge" state
action instance Icon button or button instance at the inline-end of the row
Dev

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
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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
Both

Events

  1. clickActivate
    Payload
    `{ itemId: string }`. Fires when the user activates the row via click or Enter / Space (for `interactive: true` rows). For nav-style usage where activation navigates, the consumer's router handles navigation.
    Web Components
    `clickActivate` CustomEvent on the host with `event.detail = { itemId }`.
    React
    `onClick(event)` standard or `onActivate(itemId)` for the canonical event shape.
    Angular Signals
    `output<string>('clickActivate')`.
    Vue
    `@click-activate` event with payload `{ itemId }`.
  2. selectedChange
    Payload
    `{ itemId: string, selected: boolean }`. Fires when the row's selection state changes — checkbox click, row click in `selectable` mode, or programmatic change.
    Web Components
    `selectedChange` CustomEvent with `event.detail = { itemId, selected }`.
    React
    `onSelectionChange(set: Set<string>)` at the list level (React Aria `useListBox` aggregates per-item events).
    Angular Signals
    `output<{ itemId: string, selected: boolean }>('selectedChange')`.
    Vue
    `@update:selected` per-item or `@update:selection` on the parent list.
  3. swipeAction
    Payload
    `{ itemId: string, action: string, direction: 'start' | 'end' }`. Fires when the user completes a swipe gesture revealing an action and activating it. The `action` value identifies which swipe-revealed action was triggered (typical values: `delete`, `archive`, `flag`).
    Web Components
    `swipeAction` CustomEvent with `event.detail = { itemId, action, direction }`.
    React
    Per-direction callbacks (`onSwipeLeft`, `onSwipeRight`) are common; consumers wrap to recover the canonical shape.
    Angular Signals
    `output<{ itemId: string, action: string, direction: SwipeDirection }>('swipeAction')`.
    Vue
    `@swipe-action` event.
Both

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

Performance thresholds

  • virtualisationThresholdrow-count100rows

    Above ~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-cost1ms

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

Both

Internationalisation

RTL · mirroring

Leading position (icon / avatar) moves from inline- start (visual left in LTR) to inline-start (visual right in RTL). Trailing position (badge / trailing-icon / action) mirrors. Primary and secondary text inherit document direction. Swipe-to-reveal direction reverses: in LTR, swipe-left exposes the inline-end actions; in RTL, swipe-right exposes the same logical-end actions. ArrowKey navigation is direction-neutral (vertical lists; ArrowDown / Up same in both directions).

Text expansion

Primary text follows single-line truncation with ellipsis (full text via `aria-label` for SR fallback). Secondary text wraps on two-line and three-line variants. Long-text languages may force two-line variant where one-line was authored — the canonical guideline is to choose the variant per the longest-expected locale, not the average. Density compact risks crowding long-text labels; density comfortable is safer for international list layouts.

Both

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

Accessibility acceptance

Keyboard walk

KeysExpected
TabFor 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 buttonMoves 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

TriggerExpected
Focus enters interactive rowSR 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 rowSR 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 rowSR 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-role
  • aria-required-attr
  • aria-required-children
  • aria-required-parent
  • aria-valid-attr-value
  • color-contrast
  • link-name
  • button-name
  • image-alt
Both

Common mistakes

#listitem-not-in-list

ListItem used standalone outside a list

Problem

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.

Fix

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

Problem

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.

Fix

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`

Problem

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

Fix

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

Problem

Swipeable rows expose actions on mobile via swipe gestures. Keyboard users on desktop cannot perform the swipe-revealed actions; they remain hidden.

Fix

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"

Problem

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.

Fix

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.