One row of a list-in-card: a leading icon or avatar, a title and subtitle, muted metadata on the right, and an optional actions zone. Rows are meant to sit flush inside a bordered surface, a Card body or any divide-y container, and pick up a hover background, never an underline, when interactive (list-item.tsx:6-12). href makes the whole row navigate via a stretched-link overlay, while actions stays independently clickable above it.
The canon behind it. docs/design-language/05-cards-and-surfaces.md §A8 (05-cards-and-surfaces.md:38, names ListItem outright as "the row-in-card primitive") and docs/design-language/06-tables.md §A1 (06-tables.md:15, pick the right layer, the same reasoning that draws the ListItem-versus-Table line this doc opens with).
When to reach for it#
A simple, flush, icon/avatar-led row list: contacts, files, a picker of records that are not columnar data. This is a different shape from the table cluster (table.md): one row per record with a fixed icon/title/subtitle/meta/actions layout, not a grid of arbitrary columns. Reach for Table/DataTable instead when the data actually has more than one meaningful column to show side by side.
No shipped usage was found in either app. A tree-wide grep for both the import and the JSX tag across walmart-mvp/frontend/src and speedway turned up nothing live. The only reference anywhere is a commented-out, deprecated block: speedway/app/routes/workspaces/select.tsx:39-46,176-203 imports and renders ListItem for the old workspace-selector list, but the whole route was replaced by a redirect on 2026-07-21 (doc-29, the workspace concept is hidden from users) and the original UI, ListItem included, was kept commented out verbatim so it "can be revived wholesale if workspaces come back" (select.tsx:4-9). Treat this as a dormant, not a live, precedent: it shows the component's intended shape, but nothing in either app currently renders a ListItem.
Contract#
Icon/iconSize(default16) /iconProps/iconClassName(IconImportProps): the leading glyph.avatar: a node that wins overIconin the same leading disc.title(required) /subtitle: throughrenderNode, string or JSX.titlerenders as a link (<a>orlinkAs) whenhrefis set, plain text otherwise.meta: right-aligned muted text (counts, timestamps, a role).actions: a right-side interactive zone. Stays clickable even when the row itself is a link: the link overlay is a stretchedafter:absolute after:inset-0pseudo-element on the title (list-item.tsx:83), andactionsrenders in its ownrelativewrapper so it stacks above that overlay rather than being swallowed by it (list-item.tsx:97).href/linkAs: makes the whole row navigate.linkAsinjects the app router's Link component; defaults to a plain<a>.onClick: row click handler for non-link rows; ignored whenhrefis set.active: a background tint for the current/selected row.disabled:pointer-events-noneplus half opacity.skeleton: an avatar circle, two text bars, and a trailing bar.- Slot classnames per
ListItemClassNames:""the row,iconWrap,title,subtitle,meta,actions.
The dormant precedent#
The commented-out select.tsx usage (:176-203) is worth reading as the one authored example of the full contract in use, even though it is dead code today: Icon="FileSheet", title={w.name}, subtitle={w.slug}, href/linkAs={RowLink} for row-as-link navigation, a meta node combining two counts and a Timestamp, and actions={pending ? <Spinner size="sm" /> : undefined} for an in-flight navigation indicator that stays visible over the link overlay.
Banned combinations#
None found; there is no shipped misuse because there is no shipped use.
Before you adopt this#
Five questions to answer before reaching for ListItem.
- Does the shell, a parent layout, or a global provider already render this? Not applicable, ListItem is page content; no shell already renders it.
- Does this app already ship a local implementation of the same thing? Not applicable, no local ListItem clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in? Not applicable, no shipped call site exists in either app as of this pass.
- Does the component derive its own accessible name and keyboard path, or must the call site supply them? The stretched-link title supplies the row's name;
actionscontrols must name themselves. - Which canon §A rules bind this surface, and which does the composition break? §A8 names ListItem the row-in-card primitive, distinct from a table column.
Travels with#
Card, as the flush-row container, per the source comment.EmptyState, for a list-in-card's empty state. Unconfirmed: no shipped pairing found (the dormantselect.tsxprecedent does pair the two,EmptyStatefor the zero-workspace case besideListItemfor the populated case,select.tsx:152-167).NavLinkComponent(app-shell.types.ts), the same router-link typelinkAsshares withAppShell's nav.
Snippet#
// speedway/app/routes/workspaces/select.tsx:176-203// Commented out, doc-29; shown for the shape, not as a live call site.<ListItem key={w.id} Icon="FileSheet" title={w.name} subtitle={w.slug} href={target} linkAs={RowLink} meta={ <span className="flex items-baseline gap-3"> <span> <b className="text-base-content font-semibold">{w.fileCount ?? 0}</b>{" "} {w.fileCount === 1 ? "file" : "files"} </span> <span> <b className="text-base-content font-semibold">{num(w.partCount ?? 0)}</b>{" "} SKUs </span> <span className="text-base-content/65"> <Timestamp iso={w.updatedAt} /> </span> </span> } actions={pending ? <Spinner size="sm" /> : undefined}/>