List

Presents a sequence of comparable records: files, members, notifications, settings.

Display List.jsx List.d.ts List.md

Playground

Change a prop and watch it. The code below follows along.

Install 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

  • label is required. An unlabelled list gives a screen reader no context for the count it announces.
  • Set interactive only 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 description to 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.
TokenTierDeclared as
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-font-weight-mediumprimitive500
--dt-motion-duration-fastnonenot declared
--dt-motion-easing-standardnonenot declared
--dt-surface-hovernonenot 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.