Table
Semantic table primitives for tabular data.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV-001 | Paid | Credit card | $250.00 |
| INV-002 | Pending | PayPal | $150.00 |
| INV-003 | Failed | Bank transfer | $350.00 |
| INV-004 | Paid | Credit card | $450.00 |
| Total | $1,200.00 | ||
Installation
pnpm dlx @dowel-ui/cli add tableNo 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
| Prop | Type | Default |
|---|---|---|
containerPropsProps 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
- Tested — passes
- axe assertion — passes
- Keyboard tested — passes
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
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.
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}
/>
);
}