List

A headless, stackable row layout for documents, devices, or any collection of records.

Usage

import {
  List,
  ListItem,
  ListItemContent,
  ListItemLeading,
  ListItemSubline,
  ListItemTitle,
  ListItemTrailing,
} from '@epilot/spark-ui/list'
function DocumentList() {
  return (
    <List>
      <ListItem render={<a href="/documents/1" />}>
        <ListItemLeading>
          <Icon name="edit_square" size="medium" variant="ghost" />
        </ListItemLeading>
        <ListItemContent>
          <ListItemTitle>Installation report</ListItemTitle>
          <ListItemSubline>Signed 3 May 2026</ListItemSubline>
        </ListItemContent>
        <ListItemTrailing>
          <MaterialSymbol name="chevron_right" />
        </ListItemTrailing>
      </ListItem>
    </List>
  )
}

List is fully headless: it renders row layout and neutral container chrome, but every row is composed from the parts above, so it fits documents, devices, meter readings, or a settings menu equally well. Pass render={<a href/>} or render={<button/>} on a ListItem to make that row interactive; leave it off for a static row (a value display, a heading row).

Use List for a vertical stack of records where every row shares the same shape. Reach for Accordion when each row's content expands/collapses in place, and Base UI's Menu (see Dropdown Menu) when you need a real ARIA dropdown menu of actions. List does not implement menu semantics.

Examples

Variants

Container chrome is set with the variant prop (default soft). All variants are neutral (never accent) and scale with the spacing / scaling knobs.

soft
  • Installation reportSigned 3 May 2026
  • Invoice #2026-0143Due 12 Jun 2026
surface
  • Installation reportSigned 3 May 2026
  • Invoice #2026-0143Due 12 Jun 2026
outline
  • Installation reportSigned 3 May 2026
  • Invoice #2026-0143Due 12 Jun 2026
ghost
  • Installation reportSigned 3 May 2026
  • Invoice #2026-0143Due 12 Jun 2026

Nested and external rows

A chevron_right trailing icon signals a row that navigates deeper (a nested settings screen); open_in_new signals a row that leaves the app. Both are just a MaterialSymbol in ListItemTrailing: the icon is a convention, not a prop.

Two-column value rows

ListItemContent can appear twice in one row: a left column for identity, a right column (align="end") for a value. Color the delta text yourself. The component has no opinion on positive/negative.

  • ElectricityMeter #DE00123456789
    13168 kWh+42 kWh today
  • Feed-inMeter #DE00987654321
    5320 kWh+18 kWh today

Row with an action

When a row carries an action (e.g. add, remove), put a Button in ListItemTrailing and keep the row itself static. Don't also make it interactive, or you nest an action inside a clickable row. The button supplies its own accent; the row stays neutral.

  • 1ESY12345678Keller, mitte links
  • 1ESY87654321Dachgeschoss

Full row anatomy

Every slot at once: a leading icon, a start column and an align="end" column that each stack a ListItemPreline, ListItemTitle, and ListItemSubline, plus a trailing chevron.

Selectable rows

Selection is consumer-managed: put a Checkbox (or Radio) in ListItemLeading and wire it up like any other form control. Associate it with the row's title via aria-labelledby so screen readers announce a label, not just "checkbox".

  • WallboxGarage · 11 kW
  • Heat pumpBasement · 3.2 kW
  • BatteryCellar · 10 kWh

Loading state

Pass skeleton on a ListItem for a loading placeholder row. Skeleton rows are aria-hidden, so don't mix them with real rows the user can already interact with.

A settings or profile "menu" is just a List of interactive rows: nested items get a chevron_right, external ones get open_in_new, and separate groups are separate Lists. There is no dedicated menu component: this navigation pattern is the List. Reach for Base UI's Menu only when you need a floating dropdown of actions with roving-focus keyboard semantics.

API Reference

List

PropTypeDefaultDescription
variant'soft' | 'surface' | 'outline' | 'ghost''soft'Container chrome. soft stacks separate rounded cards with a small gap; surface glues rows into one bordered, filled panel; outline is the same glued panel with no fill; ghost has no border or fill, just the rows.
classNamestringMerges with the base styles.

Renders a <ul role="list">; the role is explicit because Safari drops list semantics once list-style: none is applied. Spacing and corner radius follow the ambient spacing/scaling/radius knobs; variant does not.

ListItem

PropTypeDefaultDescription
renderuseRender.RenderPropBase UI polymorphism for the row. Pass <a href/> or <button/> to make the row interactive (accent tint on hover/focus, cursor, focus ring, real link/button semantics). Omit it for a static row: no hover, focus ring, or cursor change.
recentbooleanfalseShows a small accent dot inline before the title, for a "recently changed" row. Purely decorative; it carries no accessible text.
selectedbooleanfalsePersistent accent tint (the same tint interactive rows get on hover), for marking the current/active row.
skeletonbooleanfalseReplaces children with a loading placeholder (an avatar circle and two text bars), hides the row from assistive tech, and forces a plain, non-interactive <div>: render/selected are ignored while skeleton is set.
classNamestringMerges with the base styles.

The row is a hard 44px minimum height (WCAG 2.5.5) regardless of the spacing/scaling knobs; padding and text size still scale past that floor. Interactive-row hover/focus tint and the ListItemTrailing accent-on-hover both key off whether render is set (there is no separate interactive prop).

ListItemContent

PropTypeDefaultDescription
align'start' | 'end''start''end' right-aligns the column's text. Use it for a value column next to an identity column.
classNamestringMerges with the base styles.

A ListItem can hold more than one ListItemContent (see Two-column value rows above); each one is an independent flex column, so they lay out side by side.

Leaf parts

ListItemLeading, ListItemPreline, ListItemTitle, ListItemSubline, and ListItemTrailing are unstyled-beyond-typography wrappers (div/span): they exist to give each part of a row a consistent slot and font treatment (title is bold, preline/subline are muted gray-11), not to enforce a fixed row shape. All forward className and every other prop for their underlying element, so any HTML attribute (an id for aria-labelledby, an onClick) passes straight through.

ListItemTrailing's accent-on-hover tint works by setting a text color on the wrapper and relying on the child to inherit it via currentColor. MaterialSymbol does this correctly, but the color-independent Icon component (which hardcodes its own ink) will not visually respond. Use MaterialSymbol for the trailing affordance, not Icon.

Accessibility

  • The list exposes role="list" and each row role="listitem" regardless of interactivity, so assistive tech always reports the count of rows.
  • Interactive rows are real <a> or <button> elements via render, never a <div onClick>, so they're reachable by keyboard, get a visible focus ring, and are announced with their native role.
  • The recent dot is decorative (aria-hidden); don't rely on it alone to convey "new"; pair it with text if that state must be announced.
  • List does not implement menu, listbox, or combobox semantics. If you need arrow-key navigation and a menu/menuitem role tree (a dropdown of actions), use Base UI's Menu (see Dropdown Menu for the Spark-themed wrapper).
  • Selection controls (Checkbox, Radio) in ListItemLeading are your responsibility to label: use aria-labelledby pointing at the row's ListItemTitle id, as shown in Selectable rows.

On this page