Table

Compares records across several fields at once. Use it when the user reads across a row

Display Table.jsx Table.d.ts Table.md

Playground

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

Install and import

Table is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Table } 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

Compares records across several fields at once. Use it when the user reads across a row as often as down a column.

Rules

  • Give every table a caption. It is the accessible name and it tells a scanning reader what the rows are before they parse the headers.
  • Right-align numbers. The component turns on tabular figures for right-aligned columns so digits line up.
  • Use dense for data tables over roughly fifteen rows. Use the default padding for short summary tables where each row is a decision.
  • zebra and hairlines are alternatives, not a pair. Stripes plus borders on every row reads as noise.

Tradeoffs

Tables do not reflow. Below roughly 600px a multi-column table either scrolls sideways or has to become a List. Decide which before you ship a responsive view.

Props

The typed contract, from Table.d.ts.

import * as React from "react";

export interface TableColumn<Row = any> {
  /** Field name on the row, and the React key. */
  key: string;
  /** Column heading. */
  header: React.ReactNode;
  /** Use "right" for numeric columns — it also enables tabular figures. @default "left" */
  align?: "left" | "center" | "right";
  /** Fixed column width, e.g. "120px" or "20%". */
  width?: string;
  /** Custom cell renderer. Receives the whole row. */
  render?: (row: Row) => React.ReactNode;
}

/** Tabular data with a header row. */
export interface TableProps extends React.TableHTMLAttributes<HTMLTableElement> {
  columns: TableColumn[];
  rows: Array<Record<string, any>>;
  /** Visible description above the table. Also serves as the accessible name. */
  caption?: React.ReactNode;
  /** Tighter row padding for data-heavy views. @default false */
  dense?: boolean;
  /** Alternating row tint. @default false */
  zebra?: boolean;
}

export declare function Table(props: TableProps): React.JSX.Element;

Tokens it reads

Every custom property Table.jsx resolves, read from the source: 14 semantic, 1 primitive. 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.
TokenTierDeclared as
--dt-border-defaultsemanticvar(--dt-color-neutral-200)
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--dt-surface-subtlesemanticvar(--dt-color-neutral-050)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-font-weight-mediumprimitive500

Source

React only, no dependencies, every value a token.

import React from "react";

export function Table({ columns = [], rows = [], caption, dense = false, zebra = false, style, ...rest }) {
  const pad = dense ? "var(--dt-space-inset-xs) var(--dt-space-inset-sm)" : "var(--dt-space-inset-sm) var(--dt-space-inset-md)";
  return (
    <table style={{ width: "100%", borderCollapse: "collapse", fontFamily: "var(--dt-text-body-sm-family)", fontSize: "var(--dt-text-body-sm-size)", ...style }} {...rest}>
      {caption && <caption style={{ captionSide: "top", textAlign: "left", padding: "0 0 var(--dt-space-stack-xs)", color: "var(--dt-text-secondary)", fontSize: "var(--dt-text-body-sm-size)" }}>{caption}</caption>}
      <thead>
        <tr>
          {columns.map(c => (
            <th key={c.key} scope="col" style={{
              textAlign: c.align || "left", padding: pad,
              fontFamily: "var(--dt-text-label-sm-family)", fontSize: "var(--dt-text-label-sm-size)",
              fontWeight: "var(--dt-font-weight-medium)", color: "var(--dt-text-secondary)",
              borderBottom: "var(--dt-border-width-default) solid var(--dt-border-default)",
              whiteSpace: "nowrap", width: c.width,
            }}>{c.header}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((r, i) => (
          <tr key={r.id ?? i} style={{ background: zebra && i % 2 === 1 ? "var(--dt-surface-subtle)" : "transparent" }}>
            {columns.map(c => (
              <td key={c.key} style={{
                padding: pad, textAlign: c.align || "left", color: "var(--dt-text-primary)",
                borderBottom: "var(--dt-border-width-default) solid var(--dt-border-subtle)",
                fontVariantNumeric: c.align === "right" ? "tabular-nums" : undefined,
              }}>{c.render ? c.render(r) : r[c.key]}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

In context

Table also appears on the display reference cards, alongside the rest of its family.