← All components

Table

A table drawn with rules, not boxes, and figures that line up down their column.

InvoiceCustomerStatusAmount
INV-2041Halden & CoPaid$4,200.00
INV-2042Marlow StudioPaid$1,850.50
INV-2043Orbit FreightDue$12,940.00
INV-2044Pine AnalyticsOverdue$760.00
INV-2045Quill PressPaid$3,315.25
Total$23,065.75
Invoices issued in September.

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Table — usage19 lines
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

PropNotes
stickybooleanHold the header while the rows scroll under it, on menu glass. Pair it with `maxHeight`.
maxHeightnumber | stringScroll the rows inside this height rather than the page.
denseboolean32px rows instead of 40.
numericbooleanOn TableHead and TableCell: right-aligned, tabular numerals.
selectedbooleanOn 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.

packages/ui/src/components/data/Table.tsx130 lines
"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} />;
}