Table
A table drawn with rules, not boxes, and figures that line up down their column.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-2041 | Halden & Co | Paid | $4,200.00 |
| INV-2042 | Marlow Studio | Paid | $1,850.50 |
| INV-2043 | Orbit Freight | Due | $12,940.00 |
| INV-2044 | Pine Analytics | Overdue | $760.00 |
| INV-2045 | Quill Press | Paid | $3,315.25 |
| Total | $23,065.75 | ||
The idea
One hairline under each row and nothing else — no stripes, no vertical lines, no frame. Stripes and grid lines are what a table needs when its columns are badly aligned; aligned columns carry the eye across a row by themselves. So the alignment is done properly instead: figures sit right in tabular numerals, so a longer number is visibly a bigger one. The parts are the native elements, so colSpan, scope and a caption all still work.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption } from "mihirmodi-ui";
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead numeric>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((inv) => (
<TableRow key={inv.id}>
<TableCell>{inv.customer}</TableCell>
<TableCell numeric>{money(inv.amount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableCaption>Invoices issued in September.</TableCaption>
</Table>Props
| Prop | Type | Notes |
|---|---|---|
| stickyboolean | boolean | Hold the header while the rows scroll under it, on menu glass. Pair it with `maxHeight`. |
| maxHeightnumber | string | number | string | Scroll the rows inside this height rather than the page. |
| denseboolean | boolean | 32px rows instead of 40. |
| numericboolean | boolean | On TableHead and TableCell: right-aligned, tabular numerals. |
| selectedboolean | boolean | On TableRow: a held fill, for a chosen row. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { createContext, useContext, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from "react";
type Align = "left" | "right" | "center";
const TableContext = createContext<{ sticky: boolean; dense: boolean }>({ sticky: false, dense: false });
export type TableProps = HTMLAttributes<HTMLTableElement> & {
/**
* Keep the header row in view while the body scrolls under it. Give the table
* a `maxHeight` too, or it is the page that scrolls and the header sticks to
* the top of the window.
*/
sticky?: boolean;
/** Scroll the body inside this height, rather than the page. */
maxHeight?: number | string;
/** Rows 32px tall instead of 40. */
dense?: boolean;
/** On the scrolling box around the table. */
wrapperClassName?: string;
children: ReactNode;
};
/**
* A table drawn with rules, not boxes.
*
* One hairline under each row and nothing else — no zebra, no vertical lines,
* no frame. Stripes and grid lines are what a table needs when its columns are
* badly aligned; aligned columns carry the eye across a row by themselves, and
* every extra line is one more thing to read past. So the alignment is done
* properly instead: figures sit right, in tabular numerals, so the units line
* up down a column and a longer number is visibly a bigger one. A row under the
* pointer takes the faintest grey, enough to keep your place across a wide row.
*
* The parts are the native elements with the house look on them, so anything
* you know about tables — `colSpan`, `scope`, a `<caption>` — still works. With
* `sticky` the header holds while the rows scroll beneath it, on the frosted
* menu glass, since it is now something floating over content.
*/
export function Table({ sticky = false, maxHeight, dense = false, wrapperClassName = "", className = "", children, ...rest }: TableProps) {
return (
<TableContext.Provider value={{ sticky, dense }}>
<div className={`relative w-full overflow-auto ${wrapperClassName}`} style={maxHeight !== undefined ? { maxHeight } : undefined}>
{/* Separate borders, so a sticky header cell carries its own rule with it. */}
<table className={`w-full border-separate border-spacing-0 text-[13px] ${className}`} {...rest}>
{children}
</table>
</div>
</TableContext.Provider>
);
}
export type TableSectionProps = HTMLAttributes<HTMLTableSectionElement>;
export function TableHeader({ className = "", ...rest }: TableSectionProps) {
return <thead className={className} {...rest} />;
}
export function TableBody({ className = "", ...rest }: TableSectionProps) {
return <tbody className={className} {...rest} />;
}
/** Totals. A rule above, and the figures in medium weight — the one row that is a sum of the others. */
export function TableFooter({ className = "", ...rest }: TableSectionProps) {
return <tfoot className={`font-medium [&_td]:border-b-0 [&_td]:border-t-[0.5px] [&_td]:border-gray-alpha-500 ${className}`} {...rest} />;
}
export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
/** A held state — a selected row. Drawn as a fill, never by the text colour alone. */
selected?: boolean;
};
export function TableRow({ selected, className = "", ...rest }: TableRowProps) {
return (
<tr
data-selected={selected || undefined}
className={`transition-colors duration-150 ease-out ${
selected ? "bg-gray-alpha-100 [tbody_&]:hover:bg-gray-alpha-200" : "[tbody_&]:hover:bg-gray-alpha-100"
} ${className}`}
{...rest}
/>
);
}
const ALIGN: Record<Align, string> = { left: "text-left", right: "text-right", center: "text-center" };
export type TableHeadProps = ThHTMLAttributes<HTMLTableCellElement> & {
align?: Align;
/** A column of figures: right-aligned, so it lines up with its cells. */
numeric?: boolean;
};
export function TableHead({ align, numeric, className = "", scope = "col", ...rest }: TableHeadProps) {
const { sticky } = useContext(TableContext);
const a = align ?? (numeric ? "right" : "left");
return (
<th
scope={scope}
className={`h-9 whitespace-nowrap border-b-[0.5px] border-gray-alpha-400 px-3 align-middle text-[12px] font-medium text-muted ${ALIGN[a]} ${
sticky ? "glass-menu sticky top-0 z-10" : ""
} ${className}`}
{...rest}
/>
);
}
export type TableCellProps = TdHTMLAttributes<HTMLTableCellElement> & {
align?: Align;
/** Figures: right-aligned in tabular numerals, so the units line up down the column. */
numeric?: boolean;
};
export function TableCell({ align, numeric, className = "", ...rest }: TableCellProps) {
const { dense } = useContext(TableContext);
const a = align ?? (numeric ? "right" : "left");
return (
<td
className={`border-b-[0.5px] border-gray-alpha-400 px-3 align-middle text-gray-1000 ${dense ? "h-8 py-1" : "h-10 py-2"} ${
numeric ? "tabular-nums" : ""
} ${ALIGN[a]} ${className}`}
{...rest}
/>
);
}
/** What the table is, under it, in the meta size. Read first by a screen reader, whatever its place on screen. */
export function TableCaption({ className = "", ...rest }: HTMLAttributes<HTMLTableCaptionElement>) {
return <caption className={`mt-3 caption-bottom text-left text-[12px] leading-[1.5] text-faint ${className}`} {...rest} />;
}