List
A headless, stackable row layout for documents, devices, or any collection of records.
Usage
import { ChevronRight, EditSquare } from '@epilot/spark-icons'
import { Icon } from '@epilot/spark-ui/icon'
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 size="medium" variant="ghost">
<EditSquare />
</Icon>
</ListItemLeading>
<ListItemContent>
<ListItemTitle>Installation report</ListItemTitle>
<ListItemSubline>Signed 3 May 2026</ListItemSubline>
</ListItemContent>
<ListItemTrailing>
<ChevronRight />
</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). Every variant is neutral by default and scales with the spacing / scaling knobs; pass color="accent" to tint it with the brand accent instead.
- 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
- 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 Spark icon 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".
A row can also carry render at the same time: mark ListItemLeading with interactive and it stays outside the rendered <a>/<button>, so a checkbox and a navigable row compose safely with independent hit targets. Unlike the trailing case above, this isn't an anti-pattern to avoid.
- 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' | 'solid' | 'soft' | Container chrome. soft stacks rows with a small gap, rounded only at the top of the first row and bottom of the last (each row keeps its own subtle fill); 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; solid is the same shape as soft (gapped, first/last rounded) with a bold, inverted fill — its row ink switches with it. |
color | 'neutral' | 'accent' | 'neutral' | Chrome palette, same axis as Card. neutral (default) tracks the app neutral; accent tints soft/surface/outline's fill and solid's fill+ink with the brand accent. |
radius | 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full' | 'lg' | Corner radius of the first/last row, as a named step — same scale as Card's radius prop. |
gap | number | 0.5 | How many --spark-layout-gap units apart each row sits. Only visible on soft/solid/ghost — surface/outline glue rows together with no gap at all. |
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. Spark icons do this correctly, but the color-independent Icon component (which hardcodes its own ink) will not visually respond. Use a Spark icon for the trailing affordance, not Icon.
ListItemLeading takes one extra prop, interactive (boolean, default false): set it when the slot holds a focusable control (Checkbox, Radio, Button) and render is literally <a href/> or <button/> — that combination is the only one where nesting the control would be invalid HTML and double-fire clicks, so it's the only one ListItem hoists the control out of. Leave it unset for decorative content (an icon): it stays inside the row, so the whole visual row — icon included — is one click/tap target. The same applies when render is something else entirely (a Card, say) — a control nested inside a non-interactive element is valid HTML either way, so there's nothing to hoist out of.
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.