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.
- Installation reportSigned 3 May 2026
- Invoice #2026-0143Due 12 Jun 2026
- Installation reportSigned 3 May 2026
- Invoice #2026-0143Due 12 Jun 2026
- Installation reportSigned 3 May 2026
- Invoice #2026-0143Due 12 Jun 2026
- 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 #DE0012345678913168 kWh+42 kWh today
- Feed-inMeter #DE009876543215320 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.
Navigation menu
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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
className | string | — | Merges 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
| Prop | Type | Default | Description |
|---|---|---|---|
render | useRender.RenderProp | — | Base 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. |
recent | boolean | false | Shows a small accent dot inline before the title, for a "recently changed" row. Purely decorative; it carries no accessible text. |
selected | boolean | false | Persistent accent tint (the same tint interactive rows get on hover), for marking the current/active row. |
skeleton | boolean | false | Replaces 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. |
className | string | — | Merges 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
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'end' | 'start' | 'end' right-aligns the column's text. Use it for a value column next to an identity column. |
className | string | — | Merges 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 rowrole="listitem"regardless of interactivity, so assistive tech always reports the count of rows. - Interactive rows are real
<a>or<button>elements viarender, never a<div onClick>, so they're reachable by keyboard, get a visible focus ring, and are announced with their native role. - The
recentdot is decorative (aria-hidden); don't rely on it alone to convey "new"; pair it with text if that state must be announced. Listdoes not implement menu, listbox, or combobox semantics. If you need arrow-key navigation and amenu/menuitemrole tree (a dropdown of actions), use Base UI's Menu (see Dropdown Menu for the Spark-themed wrapper).- Selection controls (
Checkbox,Radio) inListItemLeadingare your responsibility to label: usearia-labelledbypointing at the row'sListItemTitleid, as shown in Selectable rows.