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
Plan Price / month Seats Storage Support
Starter $0 3 1 GB Community
Team $249 50 100 GB Email
Business $749 250 1 TB Priority

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.

Source src/lib/components/display/table/doc.ts · src/lib/components/display/table/Table.svelte · src/lib/components/display/table/SortableTh.svelte · src/lib/components/display/table/table.module.css

src/lib/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 {};

src/lib/components/display/table/Table.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLTableAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils/cn';
	import styles from './table.module.css';
	import { tableVariants, type TableVariants } from './table.variants';

	/* Compositional, not data-driven; DataTable is the data-driven layer. */
	let {
		caption,
		label,
		density,
		class: className = '',
		children,
		...rest
	}: Omit<HTMLTableAttributes, 'class'> &
		TableVariants & {
			/** Names the table for readers who reach it by jumping between tables. */
			caption?: string;
			/** The scroll region's name when there is no caption. */
			label?: string;
			class?: string;
			children?: Snippet;
		} = $props();
</script>

<!-- A named, focusable region, so a keyboard user can scroll a wide table
     (WCAG 2.1.1: a scrollable region must be reachable by keyboard). -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class={styles.scroll} role="region" tabindex="0" aria-label={label ?? caption ?? 'Table'}>
	<table {...rest} class={cn(tableVariants({ density }), className)}>
		{#if caption}<caption class={styles.caption}>{caption}</caption>{/if}
		{@render children?.()}
	</table>
</div>

src/lib/components/display/table/SortableTh.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { ArrowDown, ArrowUp, ArrowUpDown } from '$lib/components/utility/icon';
	import styles from './table.module.css';
	import Th from './Th.svelte';

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

	/* A column header that sorts. aria-sort states the current order; the
	   button inside it is what a keyboard user activates. */
	let {
		direction,
		onsort,
		numeric,
		shrink,
		children
	}: {
		direction: SortDirection;
		onsort: (event: MouseEvent) => void;
		numeric?: boolean;
		shrink?: boolean;
		children?: Snippet;
	} = $props();
</script>

<Th aria-sort={direction} {numeric} {shrink}>
	<button type="button" class={styles.sort} onclick={onsort}>
		{@render children?.()}
		{#if direction === 'ascending'}
			<ArrowUp aria-hidden="true" />
		{:else if direction === 'descending'}
			<ArrowDown aria-hidden="true" />
		{:else}
			<ArrowUpDown aria-hidden="true" />
		{/if}
	</button>
</Th>

src/lib/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 Knuth active18126 Sept 2026
QuartzFrances Allen active6526 Sept 2026
GarnetBarbara Liskov active21325 Sept 2026
JuniperFrances Allen archived22624 Sept 2026
KestrelFrances Allen active18414 Sept 2026
NimbusBarbara Liskov active12710 Sept 2026
PioneerGrace Hopper active12110 Sept 2026
OrchidLinus Torvalds paused2314 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.

Source src/lib/components/display/data-table/doc.ts · src/lib/components/display/data-table/DataTable.svelte · src/lib/components/display/data-table/features.ts · src/lib/components/display/data-table/data-table.module.css

src/lib/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 {};

src/lib/components/display/data-table/DataTable.svelte

<script lang="ts" generics="TData extends RowData">
	import {
		createTable,
		FlexRender,
		renderSnippet,
		type Column,
		type ColumnDef,
		type RowData,
		type SortingState
	} from '@tanstack/svelte-table';
	import type { Snippet } from 'svelte';
	import { untrack } from 'svelte';
	import { Empty } from '$lib/components/display/empty';
	import { SortableTh, Table, TBody, Td, Th, THead, Tr } from '$lib/components/display/table';
	import { Alert } from '$lib/components/feedback/alert';
	import { Skeleton } from '$lib/components/feedback/skeleton';
	import { Button } from '$lib/components/forms/button';
	import { Checkbox } from '$lib/components/forms/checkbox';
	import { Input } from '$lib/components/forms/input';
	import { Pagination } from '$lib/components/navigation/pagination';
	import { cn } from '$lib/utils/cn';
	import styles from './data-table.module.css';
	import { dataTableFeatures, type DataTableFeatures } from './features';

	type Props = {
		data: TData[];
		// eslint-disable-next-line @typescript-eslint/no-explicit-any
		columns: ColumnDef<DataTableFeatures, TData, any>[];
		/** 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';
		searchable?: boolean;
		searchPlaceholder?: string;
		selectable?: boolean;
		onSelectionChange?: (ids: string[]) => void;
		/** Shown in place of the search while rows are selected. */
		bulkActions?: Snippet<[string[], () => void]>;
		/** Controls at the end of the toolbar: filters, export, create. */
		toolbar?: Snippet;
		/** Rows per page; 0 shows every row. */
		pageSize?: number;
		initialSorting?: SortingState;
		/** Rendered after the body, e.g. a TFoot of totals. */
		footer?: Snippet;
		loading?: boolean;
		error?: string;
		onRetry?: () => void;
		/** Shown when there are no rows at all (not when a search matches none). */
		empty?: Snippet;
		class?: string;
	};

	let {
		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,
		class: className = ''
	}: Props = $props();

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

	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	const selectColumn: ColumnDef<DataTableFeatures, TData, any> = {
		id: 'select',
		enableSorting: false,
		meta: { shrink: true },
		header: ({ table }) => renderSnippet(selectAll, table),
		cell: ({ row }) => renderSnippet(selectRow, row)
	};
	const allColumns = $derived(selectable ? [selectColumn, ...columns] : columns);

	const table = createTable({
		features: dataTableFeatures,
		get columns() {
			return allColumns;
		},
		get data() {
			return data;
		},
		getRowId: (row) => getRowId(row),
		initialState: untrack(() => ({
			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
	});

	const rowSelection = $derived(table.atoms.rowSelection.get());
	const selectedIds = $derived(Object.keys(rowSelection).filter((id) => rowSelection[id]));
	const selectionKey = $derived(selectedIds.join('|'));
	$effect(() => {
		// Report changes to the selection, not to the callback's identity.
		const key = selectionKey;
		untrack(() => onSelectionChange?.(key ? key.split('|') : []));
	});

	const query = $derived(String(table.atoms.globalFilter.get() ?? ''));
	const pagination = $derived(table.atoms.pagination.get());
	const filtered = $derived(table.getFilteredRowModel().rows.length);
	const rows = $derived(table.getRowModel().rows);
	const first = $derived(filtered === 0 ? 0 : pagination.pageIndex * pagination.pageSize + 1);
	const last = $derived(Math.min(filtered, (pagination.pageIndex + 1) * pagination.pageSize));
	const clearSelection = () => table.resetRowSelection(true);

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

{#snippet selectAll(t: typeof table)}
	<Checkbox
		aria-label="Select all rows on this page"
		checked={t.getIsAllPageRowsSelected()}
		indeterminate={!t.getIsAllPageRowsSelected() && t.getIsSomePageRowsSelected()}
		onCheckedChange={(value) => t.toggleAllPageRowsSelected(value)}
	/>
{/snippet}

{#snippet selectRow(row: ReturnType<typeof table.getRowModel>['rows'][number])}
	<Checkbox
		aria-label={`Select row ${row.id}`}
		checked={row.getIsSelected()}
		disabled={!row.getCanSelect()}
		onCheckedChange={(value) => row.toggleSelected(value)}
	/>
{/snippet}

<div class={cn(styles.root, className)}>
	{#if searchable || toolbar || (selectable && bulkActions)}
		<div class={styles.toolbar}>
			{#if selectedIds.length && bulkActions}
				<div class={styles.bulk} role="status">
					{selectedIds.length} selected
					{@render bulkActions(selectedIds, clearSelection)}
					<Button size="sm" variant="quiet" onclick={clearSelection}>Clear selection</Button>
				</div>
			{:else if searchable}
				<Input
					type="search"
					size="sm"
					class={styles.search}
					aria-label={`Search ${caption}`}
					placeholder={searchPlaceholder}
					value={query}
					oninput={(event) => table.setGlobalFilter(event.currentTarget.value)}
				/>
			{:else}
				<span></span>
			{/if}
			{#if toolbar}<div class={styles.tools}>{@render toolbar()}</div>{/if}
		</div>
	{/if}
	<div class={styles.frame} aria-busy={loading || undefined}>
		<Table
			{density}
			label={caption}
			caption={hideCaption ? undefined : caption}
			aria-label={hideCaption ? caption : undefined}
		>
			<THead>
				{#each table.getHeaderGroups() as group (group.id)}
					<Tr>
						{#each group.headers as header (header.id)}
							{@const meta = header.column.columnDef.meta}
							{#if header.column.getCanSort()}
								<SortableTh
									numeric={meta?.numeric}
									shrink={meta?.shrink}
									direction={direction(header.column as Column<DataTableFeatures, RowData>)}
									onsort={(event) => header.column.getToggleSortingHandler()?.(event)}
								>
									{#if !header.isPlaceholder}<FlexRender {header} />{/if}
								</SortableTh>
							{:else}
								<Th numeric={meta?.numeric} shrink={meta?.shrink}>
									{#if !header.isPlaceholder}<FlexRender {header} />{/if}
								</Th>
							{/if}
						{/each}
					</Tr>
				{/each}
			</THead>
			<TBody>
				{#if error}
					<Tr>
						<Td colspan={allColumns.length} class={styles.state}>
							<Alert tone="crit" title="This table could not load" live="polite">
								{error}
								{#snippet action()}
									{#if onRetry}<Button size="sm" onclick={onRetry}>Try again</Button>{/if}
								{/snippet}
							</Alert>
						</Td>
					</Tr>
				{:else if loading}
					{#each { length: Math.min(pagination.pageSize, 5) }, index (index)}
						<Tr aria-hidden="true">
							{#each allColumns as column, cell (column.id ?? cell)}
								<Td><Skeleton width={cell === 0 ? '60%' : '80%'} /></Td>
							{/each}
						</Tr>
					{/each}
				{:else if data.length === 0}
					<Tr>
						<Td colspan={allColumns.length}>
							{#if empty}{@render empty()}{:else}<Empty title="Nothing here yet" />{/if}
						</Td>
					</Tr>
				{:else if filtered === 0}
					<Tr>
						<Td colspan={allColumns.length}>
							<div class={styles.noResults} role="status">
								No results for “{query}”.
								<Button size="sm" variant="quiet" onclick={() => table.setGlobalFilter('')}
									>Clear search</Button
								>
							</div>
						</Td>
					</Tr>
				{:else}
					{#each rows as row (row.id)}
						<Tr selected={row.getIsSelected()}>
							{#each row.getAllCells() as cell (cell.id)}
								{@const meta = cell.column.columnDef.meta}
								<Td numeric={meta?.numeric} shrink={meta?.shrink}><FlexRender {cell} /></Td>
							{/each}
						</Tr>
					{/each}
				{/if}
			</TBody>
			{#if footer && !loading && !error}{@render footer()}{/if}
		</Table>
	</div>
	{#if pageSize > 0 && !loading && !error && filtered > pagination.pageSize}
		<div class={styles.footer}>
			<span>{first}–{last} of {filtered}</span>
			<Pagination
				page={pagination.pageIndex + 1}
				totalPages={table.getPageCount()}
				onPageChange={(page) => table.setPageIndex(page - 1)}
				label={`${caption} pages`}
			/>
		</div>
	{/if}
</div>

src/lib/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/svelte-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>();

src/lib/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

Owner active24 days ago
Alan Turing

Alan Turing

alan.turing@northstar.dev

Member active21 days ago
Grace Hopper

Grace Hopper

grace.hopper@northstar.dev

Member invitedNever
Katherine Johnson

Katherine Johnson

katherine.johnson@northstar.dev

Member active6 days ago
Linus Torvalds

Linus Torvalds

linus.torvalds@northstar.dev

Member active22 Aug 2026
Margaret Hamilton

Margaret Hamilton

margaret.hamilton@northstar.dev

Member invitedNever

Invoices

money right-aligned · status · totals footer

Invoices
PeriodDownload
INV-2026-0018Sep 20261 Sept 2026 open$374.00
INV-2026-0017Aug 20261 Aug 2026 overdue$417.00
INV-2026-0016Jul 20261 Jul 2026 paid$374.00
INV-2026-0015Jun 20261 Jun 2026 paid$374.00
INV-2026-0014May 20261 May 2026 paid$249.00
INV-2026-0013Apr 20261 Apr 2026 paid$249.00
INV-2026-0012Mar 20261 Mar 2026 paid$249.00
INV-2026-0011Feb 20261 Feb 2026 paid$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 requests Over limit
Seats92%46 / 50 seats Near 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
deploy read
14 Mar 2026Today
Analytics export
bk_live_••••9c01
read
2 May 20267 days ago
Staging
bk_test_••••77be
read write
21 Jul 2026Never
Legacy integration
bk_live_••••0d3e
read write admin
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.