Table

Semantic table primitives for tabular data.

Invoices from the last month.
InvoiceStatusMethodAmount
INV-001PaidCredit card$250.00
INV-002PendingPayPal$150.00
INV-003FailedBank transfer$350.00
INV-004PaidCredit card$450.00
Total$1,200.00

Installation

Terminal
pnpm dlx @dowel-ui/cli add table

No npm packages are needed beyond what Dowel already requires.

Accessibility

A native table, so row and column position, header association and dimensions are all announced without ARIA. Give it a name with TableCaption or aria-label. The scrolling wrapper is focusable so an overflowing table can be scrolled by keyboard — without that, columns past the edge are unreachable. Use scope="row" on th elements inside tbody.

Props

Table

PropTypeDefault
containerProps

Props for the scrolling wrapper around the table.

ComponentPropsWithRef<"div"> & DataAttributes

Plus every attribute of <table>.

TableHeader

Plus every attribute of <thead>.

TableBody

Plus every attribute of <tbody>.

TableFooter

Plus every attribute of <tfoot>.

TableRow

Plus every attribute of <tr>.

TableHead

Plus every attribute of <th>.

TableCell

Plus every attribute of <td>.

TableCaption

Plus every attribute of <caption>.

Quality

9/9 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testedpasses
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Used in

Whole screens assembled from this component. Installing one brings this and everything else it needs with it.

Source

This is exactly what dowel add table writes into your project, with imports rewritten to your own path alias.

ui/table.tsx
import type { ComponentPropsWithRef } from "react";

import { cn } from "@/lib/utils";

/**
 * Semantic table primitives.
 *
 * A real `<table>`, not a grid of divs: screen readers announce row and column
 * position, header association and table dimensions from the native elements,
 * and none of that can be recovered with ARIA after the fact.
 *
 * The wrapper scrolls horizontally and is focusable, because a table that
 * overflows its container is otherwise unreachable by keyboard.
 */
/**
 * Arbitrary `data-*` attributes.
 *
 * TypeScript accepts `data-*` written directly on JSX, but not inside an object
 * literal typed as HTML attributes — so without this, `containerProps` could not
 * carry a test id or a styling hook.
 */
type DataAttributes = Record<`data-${string}`, string | number | boolean | undefined>;

export interface TableProps extends ComponentPropsWithRef<"table"> {
  /** Props for the scrolling wrapper around the table. */
  containerProps?: ComponentPropsWithRef<"div"> & DataAttributes;
}

export function Table({ className, containerProps, ...props }: TableProps) {
  const { className: containerClassName, ...restContainer } = containerProps ?? {};

  return (
    <div
      data-slot="table-container"
      // tabIndex 0 with a role and label makes an overflowing table scrollable
      // by keyboard. Without it, the columns past the edge are unreachable.
      tabIndex={0}
      role="region"
      aria-label={props["aria-label"] ?? "Table"}
      className={cn("relative w-full overflow-x-auto", containerClassName)}
      {...restContainer}
    >
      <table
        data-slot="table"
        className={cn("w-full caption-bottom border-collapse text-sm", className)}
        {...props}
      />
    </div>
  );
}

export function TableHeader({ className, ...props }: ComponentPropsWithRef<"thead">) {
  return (
    <thead
      data-slot="table-header"
      className={cn("[&_tr]:border-b [&_tr]:border-border", className)}
      {...props}
    />
  );
}

export function TableBody({ className, ...props }: ComponentPropsWithRef<"tbody">) {
  return (
    <tbody
      data-slot="table-body"
      className={cn("[&_tr:last-child]:border-0", className)}
      {...props}
    />
  );
}

export function TableFooter({ className, ...props }: ComponentPropsWithRef<"tfoot">) {
  return (
    <tfoot
      data-slot="table-footer"
      className={cn("border-t border-border bg-muted/40 font-medium", className)}
      {...props}
    />
  );
}

export function TableRow({ className, ...props }: ComponentPropsWithRef<"tr">) {
  return (
    <tr
      data-slot="table-row"
      className={cn(
        "border-b border-border transition-colors duration-[var(--duration-instant)]",
        "hover:bg-muted/50 data-[state=selected]:bg-accent",
        className,
      )}
      {...props}
    />
  );
}

export function TableHead({ className, ...props }: ComponentPropsWithRef<"th">) {
  return (
    <th
      data-slot="table-head"
      className={cn(
        "h-10 px-3 text-start align-middle text-xs font-medium whitespace-nowrap text-muted-foreground",
        "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0",
        className,
      )}
      {...props}
    />
  );
}

export function TableCell({ className, ...props }: ComponentPropsWithRef<"td">) {
  return (
    <td
      data-slot="table-cell"
      className={cn(
        "px-3 py-2.5 align-middle",
        "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0",
        className,
      )}
      {...props}
    />
  );
}

/**
 * A caption describing the table.
 *
 * Rendered below the table but announced as its accessible name, which is why
 * it belongs here rather than as a heading above.
 */
export function TableCaption({ className, ...props }: ComponentPropsWithRef<"caption">) {
  return (
    <caption
      data-slot="table-caption"
      className={cn("mt-4 text-sm text-muted-foreground", className)}
      {...props}
    />
  );
}