Skip to examples
Bento / Kitchen sink
Bento / compositions

Tables

Two layers: Table is semantic markup styled by tokens; DataTable adds sorting, search, pagination, and selection with TanStack Table, rendered through the same parts. The examples show how SaaS data is usually represented.

Table

static · row headers · numeric columns

Plans
PlanPrice / monthSeatsStorageSupport
Starter$031 GBCommunity
Team$24950100 GBEmail
Business$7492501 TBPriority

Row headers name the row. The plan name is a th scope="row", so a screen reader announces “Team, Seats, 50” rather than “50”. Numbers are right-aligned with tabular figures.

Sourcecomponents/display/table/doc.ts · components/display/table/table.tsx · components/display/table/sortable-th.tsx · components/display/table/table.module.css

components/display/table/doc.ts

/**
 * Table — semantic tabular markup, styled by tokens.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     Table        caption?, label?, density? ("comfortable" | "compact")
 *     THead, TBody, TFoot, Tr (selected?), Th (scope, numeric?, shrink?),
 *     Td (numeric?, shrink?), SortableTh (direction, onSort)
 *     …each forwards its element's attributes and a ref
 *
 * # Behaviour
 *
 * R1  The table sits in a named, focusable region that scrolls sideways when
 *     the table is wider than its container; the page never scrolls.
 * R2  It is named by `caption` (shown) or `label` (announced only), so a
 *     reader jumping between tables knows which this is.
 * R3  Every Th has a scope, "col" by default. A row header (scope="row")
 *     names its row and reads as body text, not as a column label.
 * R4  `numeric` cells are right-aligned; every cell uses tabular figures, so
 *     columns of numbers compare by eye.
 * R5  Rows darken on hover; a `selected` row is tinted with `--accent-tint`
 *     and reports aria-selected.
 * R6  `compact` tightens cell padding and text size, for logs and dense data.
 * R7  SortableTh reports its order in aria-sort and holds a button that sorts;
 *     the caller owns the order.
 *
 * # Use it for
 *
 * Static or server-rendered tables: plans, specs, a short list. For sorting,
 * searching, paging, and selecting, use DataTable.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Compositional rather than data-driven: a table that takes columns and rows
 * needs a renderer prop per non-string cell and reinvents markup the platform
 * already has. DataTable is the data-driven layer, built from these parts.
 */
export {};

components/display/table/table.tsx

import type { ComponentPropsWithRef, ReactNode } from "react";

import { cn } from "@/lib/utils/cn";
import styles from "./table.module.css";
import { tableVariants, type TableVariants } from "./table.variants";

/* Compositional, not data-driven: a <Table rows columns /> needs a renderer
   prop per non-string cell and reinvents markup the platform already has.
   DataTable is the data-driven layer, built on these parts. */

export type TableProps = ComponentPropsWithRef<"table"> &
  TableVariants & {
    /** Names the table for readers who reach it by jumping between tables. */
    caption?: ReactNode;
    /** The scroll region's name when there is no caption. */
    label?: string;
  };

export function Table({
  caption,
  label,
  density,
  className,
  children,
  ...props
}: TableProps) {
  return (
    // A named, focusable region, so a keyboard user can scroll a wide table.
    <div
      className={styles.scroll}
      role="region"
      tabIndex={0}
      aria-label={label ?? (typeof caption === "string" ? caption : "Table")}
    >
      <table {...props} className={cn(tableVariants({ density }), className)}>
        {caption ? (
          <caption className={styles.caption}>{caption}</caption>
        ) : null}
        {children}
      </table>
    </div>
  );
}

export function THead({ className, ...props }: ComponentPropsWithRef<"thead">) {
  return <thead {...props} className={cn(styles.head, className)} />;
}

export function TBody(props: ComponentPropsWithRef<"tbody">) {
  return <tbody {...props} />;
}

export function TFoot({ className, ...props }: ComponentPropsWithRef<"tfoot">) {
  return <tfoot {...props} className={cn(styles.foot, className)} />;
}

export type TrProps = ComponentPropsWithRef<"tr"> & { selected?: boolean };

export function Tr({ selected, className, ...props }: TrProps) {
  return (
    <tr
      {...props}
      data-selected={selected || undefined}
      aria-selected={selected}
      className={cn(styles.row, className)}
    />
  );
}

type CellOptions = {
  /** Right-aligned with tabular figures. */
  numeric?: boolean;
  /** As narrow as its content: checkboxes, row actions. */
  shrink?: boolean;
};

export type ThProps = ComponentPropsWithRef<"th"> & CellOptions;

/** Always carries a scope, so readers know whether it heads a column or a
 *  row. Column by default. */
export function Th({
  numeric,
  shrink,
  scope = "col",
  className,
  ...props
}: ThProps) {
  return (
    <th
      {...props}
      scope={scope}
      className={cn(
        styles.th,
        numeric && styles.numeric,
        shrink && styles.shrink,
        className,
      )}
    />
  );
}

export type TdProps = ComponentPropsWithRef<"td"> & CellOptions;

export function Td({ numeric, shrink, className, ...props }: TdProps) {
  return (
    <td
      {...props}
      className={cn(
        styles.td,
        numeric && styles.numeric,
        shrink && styles.shrink,
        className,
      )}
    />
  );
}

components/display/table/sortable-th.tsx

"use client";

import type { ReactNode } from "react";

import { ArrowDown, ArrowUp, ArrowUpDown } from "@/components/utility/icon";
import styles from "./table.module.css";
import { Th, type ThProps } from "./table";

export type SortDirection = "ascending" | "descending" | "none";

export type SortableThProps = Omit<ThProps, "children" | "aria-sort"> & {
  children: ReactNode;
  direction: SortDirection;
  onSort: (event: React.MouseEvent<HTMLButtonElement>) => void;
};

/** A column header that sorts. aria-sort states the current order; the button
 *  inside it is what a keyboard user activates. */
export function SortableTh({
  children,
  direction,
  onSort,
  ...props
}: SortableThProps) {
  const Icon =
    direction === "ascending"
      ? ArrowUp
      : direction === "descending"
        ? ArrowDown
        : ArrowUpDown;
  return (
    <Th {...props} aria-sort={direction}>
      <button type="button" className={styles.sort} onClick={onSort}>
        {children}
        <Icon aria-hidden="true" />
      </button>
    </Th>
  );
}

components/display/table/table.module.css

@layer primitive {
  /* Wide tables scroll inside their own region, never the page. */
  .scroll {
    /* The containing block for anything positioned inside, so visually hidden
       text in a cell is clipped here instead of widening the page. */
    position: relative;
    min-width: 0;
    overflow-x: auto;
    border-radius: inherit;
  }
  .scroll:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .table {
    --cell-y: var(--space-5);
    --cell-x: var(--space-6);
    width: 100%;
    border-collapse: collapse;
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    font-variant-numeric: tabular-nums;
  }
  .compact {
    --cell-y: var(--space-3);
    --cell-x: var(--space-5);
    font-size: var(--text-12);
  }
  /* The table's name, as a title bar aligned to the cell grid. */
  .caption {
    padding: var(--space-5) var(--cell-x);
    border-bottom: 1px solid var(--line);
    color: var(--ink);
    font-size: var(--text-body, var(--text-13));
    font-weight: var(--weight-strong);
    text-align: start;
    caption-side: top;
  }
  .head {
    background: var(--surface-panel-2);
  }
  .th {
    padding: var(--cell-y) var(--cell-x);
    border-bottom: 1px solid var(--line-strong);
    color: var(--ink-3);
    font-size: var(--text-11);
    font-weight: var(--weight-strong);
    letter-spacing: var(--tracking-label);
    text-align: start;
    text-transform: uppercase;
    white-space: nowrap;
  }
  .td {
    padding: var(--cell-y) var(--cell-x);
    border-bottom: 1px solid var(--line);
    color: var(--ink);
    vertical-align: middle;
  }
  .row:last-child > .td {
    border-bottom: 0;
  }
  /* A row header (scope="row") reads as the row's name, not a column label. */
  .row > .th {
    border-bottom: 1px solid var(--line);
    color: var(--ink);
    font-size: inherit;
    font-weight: var(--weight-medium);
    letter-spacing: normal;
    text-transform: none;
  }
  .row:last-child > .th {
    border-bottom: 0;
  }
  .row:hover > :is(.td, .th) {
    background: var(--surface-hover);
  }
  .row[data-selected] > :is(.td, .th) {
    background: var(--accent-tint);
  }
  .foot > .row > :is(.td, .th) {
    border-top: 1px solid var(--line-strong);
    border-bottom: 0;
    font-weight: var(--weight-strong);
  }
  /* Numbers align on their digits so a column can be compared by eye. */
  .numeric {
    text-align: end;
  }
  .shrink {
    width: 1%;
    white-space: nowrap;
  }
  .sort {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    margin: calc(var(--space-2) * -1);
    padding: var(--space-2);
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: inherit;
    font: inherit;
    letter-spacing: inherit;
    text-transform: inherit;
    cursor: pointer;
  }
  .sort svg {
    width: 12px;
    height: 12px;
    opacity: 0.6;
  }
  .sort:hover {
    color: var(--ink);
  }
  .sort:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 0;
  }
  .th[aria-sort="ascending"],
  .th[aria-sort="descending"] {
    color: var(--ink);
  }
  .th[aria-sort="ascending"] svg,
  .th[aria-sort="descending"] svg {
    opacity: 1;
  }
}

DataTable

sort · search · paginate · select

Projects
BeaconDonald Knuthactive18126 Sept 2026
QuartzFrances Allenactive6526 Sept 2026
GarnetBarbara Liskovactive21325 Sept 2026
JuniperFrances Allenarchived22624 Sept 2026
KestrelFrances Allenactive18414 Sept 2026
NimbusBarbara Liskovactive12710 Sept 2026
PioneerGrace Hopperactive12110 Sept 2026
OrchidLinus Torvaldspaused2314 Sept 2026

Selected ids: none

Selection is by id, not position. Rows are selected by getRowId, so a selection survives sorting, searching, and paging. The header checkbox selects the current page.

Sourcecomponents/display/data-table/doc.ts · components/display/data-table/data-table.tsx · components/display/data-table/features.ts · components/display/data-table/data-table.module.css

components/display/data-table/doc.ts

/**
 * DataTable — a table of records you can sort, search, page, and select.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     data, columns, getRowId, caption            REQUIRED
 *     searchable?, searchPlaceholder?
 *     selectable?, onSelectionChange?(ids), bulkActions?(ids, clear)
 *     toolbar?, pageSize? (default 10; 0 shows every row), initialSorting?
 *     density?, hideCaption?, footer?
 *     loading?, error?, onRetry?, empty?
 *
 * Columns are built with createDataTableColumns<Row>(); `meta.numeric` and
 * `meta.shrink` set cell alignment and width.
 *
 * # Behaviour
 *
 * R1  Sortable columns sort on their header button, ascending then
 *     descending; the order is announced through aria-sort.
 * R2  Search filters every column except selection and actions, and resets to
 *     the first page. A search with no matches says "No results for …" with a
 *     button to clear it — distinct from an empty table.
 * R3  Rows are identified by `getRowId`, never by position, so a selection
 *     survives sorting, searching, and paging. The header checkbox selects or
 *     clears the current page and shows a dash when the page is partly
 *     selected. While rows are selected, `bulkActions` replaces the search.
 * R4  Pagination shows "first–last of total" and page buttons only when there
 *     is more than one page.
 * R5  States, each different: `loading` shows skeleton rows in an aria-busy
 *     region; `error` shows an Alert with a retry; no rows shows `empty` (an
 *     Empty by default); a search with no matches is R2.
 * R6  Everything in R1–R5 is built from Table, so it inherits Table's naming,
 *     scrolling, scope, and numeric rules.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * TanStack Table v9 supplies the state and row models; this component owns
 * all markup. The feature set is fixed in features.ts (sorting, column and
 * global filtering, pagination, selection) so every table has the same APIs.
 * Selection toggles through row.toggleSelected because the checkbox is a
 * button, not an input: Shift-click range selection is not wired yet.
 */
export {};

components/display/data-table/data-table.tsx

"use client";

import { useEffect, useMemo, type ReactNode } from "react";
import {
  useTable,
  type Column,
  type ColumnDef,
  type RowData,
  type SortingState,
} from "@tanstack/react-table";

import { Empty } from "@/components/display/empty";
import {
  SortableTh,
  Table,
  TBody,
  Td,
  Th,
  THead,
  Tr,
} from "@/components/display/table";
import { Alert } from "@/components/feedback/alert";
import { Skeleton } from "@/components/feedback/skeleton";
import { Button } from "@/components/forms/button";
import { Checkbox } from "@/components/forms/checkbox";
import { Input } from "@/components/forms/input";
import { Pagination } from "@/components/navigation/pagination";
import { cn } from "@/lib/utils/cn";
import styles from "./data-table.module.css";
import { dataTableFeatures, type DataTableFeatures } from "./features";

/* A column definition built with createDataTableColumns(). Columns in one
   table have different value types, so the value type is left open here. */
export type DataTableColumn<TData extends RowData> = ColumnDef<
  DataTableFeatures,
  TData,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  any
>;

export type DataTableProps<TData extends RowData> = {
  data: TData[];
  columns: DataTableColumn<TData>[];
  /** A stable id per row: selection survives sorting, paging, and refetches. */
  getRowId: (row: TData) => string;
  /** Names the table; shown above it unless hideCaption. */
  caption: string;
  hideCaption?: boolean;
  density?: "comfortable" | "compact";
  /** Show a search box that filters every searchable column. */
  searchable?: boolean;
  searchPlaceholder?: string;
  /** Add a checkbox column. */
  selectable?: boolean;
  onSelectionChange?: (ids: string[]) => void;
  /** Shown in place of the toolbar while rows are selected. */
  bulkActions?: (ids: string[], clear: () => void) => ReactNode;
  /** Controls at the end of the toolbar: filters, export, create. */
  toolbar?: ReactNode;
  /** Rows per page; 0 shows every row. */
  pageSize?: number;
  initialSorting?: SortingState;
  /** Rendered after the body, e.g. a TFoot of totals. */
  footer?: ReactNode;
  loading?: boolean;
  /** A failure message; replaces the rows with an Alert. */
  error?: ReactNode;
  onRetry?: () => void;
  /** Shown when there are no rows at all (not when a search matches none). */
  empty?: ReactNode;
  className?: string;
};

const NOT_SEARCHABLE = new Set(["select", "actions"]);

function direction(column: Column<DataTableFeatures, RowData>) {
  const sorted = column.getIsSorted();
  return sorted === "asc"
    ? "ascending"
    : sorted === "desc"
      ? "descending"
      : "none";
}

export function DataTable<TData extends RowData>({
  data,
  columns,
  getRowId,
  caption,
  hideCaption = false,
  density,
  searchable = false,
  searchPlaceholder = "Search",
  selectable = false,
  onSelectionChange,
  bulkActions,
  toolbar,
  pageSize = 10,
  initialSorting = [],
  footer,
  loading = false,
  error,
  onRetry,
  empty,
  className,
}: DataTableProps<TData>) {
  const allColumns = useMemo<DataTableColumn<TData>[]>(
    () =>
      selectable
        ? [
            {
              id: "select",
              enableSorting: false,
              meta: { shrink: true },
              header: ({ table }) => (
                <Checkbox
                  aria-label="Select all rows on this page"
                  checked={
                    table.getIsAllPageRowsSelected()
                      ? true
                      : table.getIsSomePageRowsSelected()
                        ? "indeterminate"
                        : false
                  }
                  onCheckedChange={(value) =>
                    table.toggleAllPageRowsSelected(value === true)
                  }
                />
              ),
              cell: ({ row }) => (
                <Checkbox
                  aria-label={`Select row ${row.id}`}
                  checked={row.getIsSelected()}
                  disabled={!row.getCanSelect()}
                  onCheckedChange={(value) =>
                    row.toggleSelected(value === true)
                  }
                />
              ),
            },
            ...columns,
          ]
        : columns,
    [columns, selectable],
  );

  const table = useTable(
    {
      features: dataTableFeatures,
      columns: allColumns,
      data,
      getRowId,
      initialState: {
        sorting: initialSorting,
        pagination: { pageIndex: 0, pageSize: pageSize || data.length || 1 },
      },
      enableSortingRemoval: false,
      globalFilterFn: "includesString",
      getColumnCanGlobalFilter: (column) =>
        !NOT_SEARCHABLE.has(column.id) &&
        (column.columnDef as { enableGlobalFilter?: boolean })
          .enableGlobalFilter !== false,
    },
    (state) => ({
      globalFilter: state.globalFilter,
      pagination: state.pagination,
      rowSelection: state.rowSelection,
      sorting: state.sorting,
    }),
  );

  const selectedIds = Object.keys(table.state.rowSelection).filter(
    (id) => table.state.rowSelection[id],
  );
  const selectionKey = selectedIds.join("|");
  useEffect(() => {
    onSelectionChange?.(selectionKey ? selectionKey.split("|") : []);
    // Report changes to the selection, not to the callback's identity.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [selectionKey]);

  const query = String(table.state.globalFilter ?? "");
  const filtered = table.getFilteredRowModel().rows.length;
  const rows = table.getRowModel().rows;
  const { pageIndex, pageSize: size } = table.state.pagination;
  const first = filtered === 0 ? 0 : pageIndex * size + 1;
  const last = Math.min(filtered, (pageIndex + 1) * size);
  const columnCount = allColumns.length;
  const clearSelection = () => table.resetRowSelection(true);

  let body: ReactNode;
  if (error) {
    body = (
      <Tr>
        <Td colSpan={columnCount} className={styles.state}>
          <Alert
            tone="crit"
            title="This table could not load"
            live="polite"
            action={
              onRetry ? (
                <Button size="sm" onClick={onRetry}>
                  Try again
                </Button>
              ) : null
            }
          >
            {error}
          </Alert>
        </Td>
      </Tr>
    );
  } else if (loading) {
    body = Array.from({ length: Math.min(size, 5) }, (_, index) => (
      <Tr key={index} aria-hidden="true">
        {allColumns.map((column, cell) => (
          <Td key={column.id ?? cell}>
            <Skeleton width={cell === 0 ? "60%" : "80%"} />
          </Td>
        ))}
      </Tr>
    ));
  } else if (data.length === 0) {
    body = (
      <Tr>
        <Td colSpan={columnCount}>
          {empty ?? <Empty title="Nothing here yet" />}
        </Td>
      </Tr>
    );
  } else if (filtered === 0) {
    body = (
      <Tr>
        <Td colSpan={columnCount}>
          <div className={styles.noResults} role="status">
            No results for “{query}”.
            <Button
              size="sm"
              variant="quiet"
              onClick={() => table.setGlobalFilter("")}
            >
              Clear search
            </Button>
          </div>
        </Td>
      </Tr>
    );
  } else {
    body = rows.map((row) => (
      <Tr key={row.id} selected={row.getIsSelected()}>
        {row.getAllCells().map((cell) => {
          const meta = cell.column.columnDef.meta;
          return (
            <Td key={cell.id} numeric={meta?.numeric} shrink={meta?.shrink}>
              <table.FlexRender cell={cell} />
            </Td>
          );
        })}
      </Tr>
    ));
  }

  const hasToolbar = searchable || toolbar || (selectable && bulkActions);

  return (
    <div className={cn(styles.root, className)}>
      {hasToolbar ? (
        <div className={styles.toolbar}>
          {selectedIds.length && bulkActions ? (
            <div className={styles.bulk} role="status">
              {selectedIds.length} selected
              {bulkActions(selectedIds, clearSelection)}
              <Button size="sm" variant="quiet" onClick={clearSelection}>
                Clear selection
              </Button>
            </div>
          ) : searchable ? (
            <Input
              type="search"
              size="sm"
              className={styles.search}
              aria-label={`Search ${caption}`}
              placeholder={searchPlaceholder}
              value={query}
              onChange={(event) => table.setGlobalFilter(event.target.value)}
            />
          ) : (
            <span />
          )}
          {toolbar ? <div className={styles.tools}>{toolbar}</div> : null}
        </div>
      ) : null}
      <div className={styles.frame} aria-busy={loading || undefined}>
        <Table
          density={density}
          label={caption}
          caption={hideCaption ? undefined : caption}
          aria-label={hideCaption ? caption : undefined}
        >
          <THead>
            {table.getHeaderGroups().map((group) => (
              <Tr key={group.id}>
                {group.headers.map((header) => {
                  const meta = header.column.columnDef.meta;
                  const content = header.isPlaceholder ? null : (
                    <table.FlexRender header={header} />
                  );
                  return header.column.getCanSort() ? (
                    <SortableTh
                      key={header.id}
                      numeric={meta?.numeric}
                      shrink={meta?.shrink}
                      direction={direction(
                        header.column as Column<DataTableFeatures, RowData>,
                      )}
                      onSort={(event) =>
                        header.column.getToggleSortingHandler()?.(event)
                      }
                    >
                      {content}
                    </SortableTh>
                  ) : (
                    <Th
                      key={header.id}
                      numeric={meta?.numeric}
                      shrink={meta?.shrink}
                    >
                      {content}
                    </Th>
                  );
                })}
              </Tr>
            ))}
          </THead>
          <TBody>{body}</TBody>
          {footer && !loading && !error ? footer : null}
        </Table>
      </div>
      {pageSize > 0 && !loading && !error && filtered > size ? (
        <div className={styles.footer}>
          <span>
            {first}–{last} of {filtered}
          </span>
          <Pagination
            page={pageIndex + 1}
            totalPages={table.getPageCount()}
            onPageChange={(page) => table.setPageIndex(page - 1)}
            label={`${caption} pages`}
          />
        </div>
      ) : null}
    </div>
  );
}

components/display/data-table/features.ts

import {
  columnFilteringFeature,
  createColumnHelper,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  type RowData,
  sortFn_alphanumeric,
  sortFn_basic,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
} from "@tanstack/react-table";

/** Per-column presentation, read by DataTable when it renders cells. */
export type DataTableColumnMeta = {
  /** Right-aligned with tabular figures: amounts, counts, percentages. */
  numeric?: boolean;
  /** As narrow as its content: status, actions. */
  shrink?: boolean;
};

/** The one feature set every DataTable registers. Features and row models
 *  are explicit in TanStack v9; a missing one means a missing API. */
export const dataTableFeatures = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: {
    alphanumeric: sortFn_alphanumeric,
    basic: sortFn_basic,
    datetime: sortFn_datetime,
    text: sortFn_text,
  },
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: { includesString: filterFn_includesString },
  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),
  rowSelectionFeature,
  columnMeta: {} as DataTableColumnMeta,
});

export type DataTableFeatures = typeof dataTableFeatures;

/** A column helper typed against DataTable's features and meta. */
export const createDataTableColumns = <TData extends RowData>() =>
  createColumnHelper<DataTableFeatures, TData>();

components/display/data-table/data-table.module.css

@layer composition {
  .root {
    display: grid;
    min-width: 0;
    gap: var(--space-5);
  }
  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    min-height: var(--control-md);
  }
  .search {
    width: min(100%, 18rem);
  }
  .tools,
  .bulk {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
  }
  .bulk {
    color: var(--ink-2);
    font-size: var(--text-12);
  }
  .frame {
    overflow: hidden;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
  }
  .state {
    padding: var(--space-6);
  }
  .state > * {
    margin-inline: auto;
  }
  .noResults {
    display: grid;
    justify-items: center;
    gap: var(--space-3);
    padding: var(--space-8);
    color: var(--ink-2);
    text-align: center;
  }
  .footer {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4);
    color: var(--ink-3);
    font-size: var(--text-12);
    font-variant-numeric: tabular-nums;
  }
}

Members

identity cell · role and status · row actions · bulk remove

Members
Actions
Ada Lovelace

Ada Lovelace

ada.lovelace@northstar.dev

Owneractive24 days ago
Alan Turing

Alan Turing

alan.turing@northstar.dev

Memberactive21 days ago
Grace Hopper

Grace Hopper

grace.hopper@northstar.dev

MemberinvitedNever
Katherine Johnson

Katherine Johnson

katherine.johnson@northstar.dev

Memberactive6 days ago
Linus Torvalds

Linus Torvalds

linus.torvalds@northstar.dev

Memberactive22 Aug 2026
Margaret Hamilton

Margaret Hamilton

margaret.hamilton@northstar.dev

MemberinvitedNever

Invoices

money right-aligned · status · totals footer

Invoices
PeriodDownload
INV-2026-0018Sep 20261 Sept 2026open$374.00
INV-2026-0017Aug 20261 Aug 2026overdue$417.00
INV-2026-0016Jul 20261 Jul 2026paid$374.00
INV-2026-0015Jun 20261 Jun 2026paid$374.00
INV-2026-0014May 20261 May 2026paid$249.00
INV-2026-0013Apr 20261 Apr 2026paid$249.00
INV-2026-0012Mar 20261 Mar 2026paid$249.00
INV-2026-0011Feb 20261 Feb 2026paid$249.00
Paid this year$1,744.00

Money is stored in cents. Amounts are integers formatted at render, so totals never pick up floating-point error.

Usage

meters against plan limits

Usage this billing period
ResourceUsageLimitState
API requests102%1,020,000 / 1,000,000 requestsOver limit
Seats92%46 / 50 seatsNear limit
Storage71%71.4 / 100 GB
Automation runs32%3,180 / 10,000 runs
Projects24%24 / 100 projects

Audit log

compact · timestamps · filterable

Audit log
IP
26 Sept, 17:24 UTC
Linus TorvaldsLinus Torvalds
project.archivedIris10.69.195.96
26 Sept, 15:56 UTC
Ada LovelaceAda Lovelace
member.invitedBarbara Liskov10.236.83.59
26 Sept, 13:54 UTC
Linus TorvaldsLinus Torvalds
api_key.createdCI deploy key10.195.244.232
26 Sept, 12:57 UTC
Katherine JohnsonKatherine Johnson
member.role_changedKatherine Johnson10.252.19.198
26 Sept, 11:26 UTC
Alan TuringAlan Turing
project.archivedQuartz10.153.157.226
26 Sept, 09:50 UTC
Katherine JohnsonKatherine Johnson
project.createdTundra10.44.220.99
26 Sept, 07:22 UTC
Ada LovelaceAda Lovelace
project.archivedWillow10.68.88.11
26 Sept, 06:27 UTC
Alan TuringAlan Turing
billing.plan_changedTeam → Business10.170.185.59
26 Sept, 04:40 UTC
Linus TorvaldsLinus Torvalds
session.signed_inWeb10.70.197.129
26 Sept, 03:25 UTC
Grace HopperGrace Hopper
member.invitedBarbara Liskov10.28.172.226

API keys

masked secrets · copy · revoke

API keys
KeyScopesRevoke
CI deploy key
bk_live_••••4f2a
deployread
14 Mar 2026Today
Analytics export
bk_live_••••9c01
read
2 May 20267 days ago
Staging
bk_test_••••77be
readwrite
21 Jul 2026Never
Legacy integration
bk_live_••••0d3e
readwriteadmin
8 Nov 202511 Feb 2026

A secret is shown once, then masked. The table keeps a prefix and the last four characters, enough to recognise a key without exposing it.

States

loading · empty · error

Loading
Empty

No projects yet

Projects group the work your team ships.

Error

This table could not load

The server did not respond. Your data is safe.

Each state says something different. Loading shows skeleton rows in a busy region; empty invites the first action; an error explains and offers a retry. “No results” for a search is a fourth state, with a way to clear it.