List
Presents a sequence of comparable records: files, members, notifications, settings.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
List is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { List } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Presents a sequence of comparable records: files, members, notifications, settings. Use it when rows share a shape and the user scans down one column.
Rules
labelis required. An unlabelled list gives a screen reader no context for the count it announces.- Set
interactiveonly when rows navigate or select. A row that looks clickable and does nothing is worse than a plain row. - One trailing action per row. Two competing controls in a row make the hit target ambiguous on touch.
- Keep
descriptionto one line. If a row needs more, it is a Card, not a list item.
Tradeoffs
Lists scan faster than tables but carry less data per row. Once you need more than a title, a description, and one piece of metadata, move to Table.
Props
The typed contract, from List.d.ts.
import * as React from "react";
export interface ListItem {
id?: string | number;
/** Primary line. */
title: React.ReactNode;
/** Secondary line under the title. */
description?: React.ReactNode;
/** Slot before the text — an Avatar, icon, or checkbox. */
leading?: React.ReactNode;
/** Slot after the text — a Badge, action, or chevron. */
trailing?: React.ReactNode;
/** Makes the row a button when the list is interactive. */
onClick?: () => void;
}
/** Vertical row list with optional leading and trailing slots. */
export interface ListProps extends React.HTMLAttributes<HTMLUListElement> {
items: ListItem[];
/** Hairline between rows. @default true */
divided?: boolean;
/** Renders rows with onClick as buttons with hover feedback. @default false */
interactive?: boolean;
/** Accessible list label. Required. */
label: string;
}
export declare function List(props: ListProps): React.JSX.Element;
Tokens it reads
Every custom property List.jsx resolves, read from the source: 14 semantic, 1 primitive, 3 undeclared. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- primitive Raw values. A component reading one directly is a deliberate exception.
- none Referenced by the source but declared by no tier, so it resolves to nothing.
| Token | Tier | Declared as |
|---|---|---|
--dt-border-subtle | semantic | var(--dt-color-neutral-100) |
--dt-border-width-default | semantic | var(--dt-dim-hair) |
--dt-space-inline-sm | semantic | var(--dt-dim-3) |
--dt-space-inline-xs | semantic | var(--dt-dim-2) |
--dt-space-inset-md | semantic | var(--dt-dim-4) |
--dt-space-inset-sm | semantic | var(--dt-dim-3) |
--dt-text-body-md-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-md-line | semantic | var(--dt-line-height-md) |
--dt-text-body-md-size | semantic | var(--dt-font-size-md) |
--dt-text-body-sm-family | semantic | var(--dt-font-family-sans) |
--dt-text-body-sm-line | semantic | var(--dt-line-height-sm) |
--dt-text-body-sm-size | semantic | var(--dt-font-size-sm) |
--dt-text-primary | semantic | var(--dt-color-neutral-900) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-font-weight-medium | primitive | 500 |
--dt-motion-duration-fast | none | not declared |
--dt-motion-easing-standard | none | not declared |
--dt-surface-hover | none | not declared |
Source
React only, no dependencies, every value a token.
import React from "react";
export function List({ items = [], divided = true, interactive = false, label, style, ...rest }) {
return (
<ul role="list" aria-label={label} style={{ listStyle: "none", margin: 0, padding: 0, ...style }} {...rest}>
{items.map((it, i) => {
const clickable = interactive && it.onClick;
const Row = clickable ? "button" : "div";
return (
<li key={it.id ?? i} style={{ borderTop: i === 0 || !divided ? "none" : "var(--dt-border-width-default) solid var(--dt-border-subtle)" }}>
<Row
onClick={it.onClick}
type={clickable ? "button" : undefined}
style={{
display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)",
width: "100%", boxSizing: "border-box", textAlign: "left",
padding: "var(--dt-space-inset-sm) var(--dt-space-inset-md)",
background: "transparent", border: "none", font: "inherit", color: "inherit",
cursor: clickable ? "pointer" : "default",
transition: "background var(--dt-motion-duration-fast) var(--dt-motion-easing-standard)",
}}
onMouseEnter={clickable ? e => (e.currentTarget.style.background = "var(--dt-surface-hover)") : undefined}
onMouseLeave={clickable ? e => (e.currentTarget.style.background = "transparent") : undefined}
>
{it.leading && <span style={{ flex: "none", display: "flex" }}>{it.leading}</span>}
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{
display: "block", fontFamily: "var(--dt-text-body-md-family)", fontSize: "var(--dt-text-body-md-size)",
lineHeight: "var(--dt-text-body-md-line)", fontWeight: "var(--dt-font-weight-medium)", color: "var(--dt-text-primary)",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap",
}}>{it.title}</span>
{it.description && (
<span style={{
display: "block", fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)",
lineHeight: "var(--dt-text-body-sm-line)", color: "var(--dt-text-secondary)",
}}>{it.description}</span>
)}
</span>
{it.trailing && <span style={{ flex: "none", display: "flex", alignItems: "center", gap: "var(--dt-space-inline-xs)" }}>{it.trailing}</span>}
</Row>
</li>
);
})}
</ul>
);
}
In context
List also appears on the display reference cards, alongside the rest of its family.