Skip to content
omaris

Data Display

Table

Data table.

import { Table } from 'omaris'
Learn

Examples

Basic

Columns and rows are enough for a read-only list. A column reads row[key], and format says how a number shows — here any Intl currency, in Western digits.

OrderCustomerCityTotal
4021Kurdistan RoastersErbilIQD 480,000
4022Dijla GrocersBaghdadIQD 125,000
4023Shatt CaféBasraIQD 62,500
<script lang="ts">	import { Table, type TableColumn } from 'omaris';​	type Order = { id: string; customer: string; city: string; total: number };​	const ROWS: Order[] = [		{ id: '4021', customer: 'Kurdistan Roasters', city: 'Erbil', total: 480000 },		{ id: '4022', customer: 'Dijla Grocers', city: 'Baghdad', total: 125000 },		{ id: '4023', customer: 'Shatt Café', city: 'Basra', total: 62500 }	];​	const COLUMNS: TableColumn<Order>[] = [		{ key: 'id', header: 'Order' },		{ key: 'customer', header: 'Customer' },		{ key: 'city', header: 'City' },		{			key: 'total',			header: 'Total',			format: { style: 'currency', currency: 'IQD', maximumFractionDigits: 0 }		}	];</script>​<Table columns={COLUMNS} rows={ROWS} label="Recent orders" />

Ledger

A ledger the way a spreadsheet reads it: ruled cells, row numbers, currencies grouped over their columns, totals under them, and a button that gives the sheet the whole screen.

#USDIQD
DebitCreditDebitCredit
1Ahmed Jasim1,653.9953,077
2Ali Hussein7,663.5612,537,676
3Mohammed Karim553.99395,629,181
4Mustafa Nouri101.00871,851
5Omar Shakir106.50720,345
6Yousif Tariq61.00171,966
7Zaid Firas1,001,030.68573,400
8Haider Adil590.005,360,630
9Fatima Ali7,705.00388,460
10Ruqaya Shihab1,274.0012,000,000
Total1,380.501,019,359.221,764,162426,542,424
Net-1,017,978.72-424,778,262
<script lang="ts">	import { Table, formatCell, type TableColumn } from 'omaris';​	type Account = {		name: string;		usdDebit?: number;		usdCredit?: number;		iqdDebit?: number;		iqdCredit?: number;		moved: Date;	};​	const ROWS: Account[] = [		{ name: 'Ahmed Jasim', usdCredit: 1653.99, iqdCredit: 53077, moved: new Date(2026, 7, 25) },		{ name: 'Ali Hussein', usdCredit: 7663.56, iqdCredit: 12537676, moved: new Date(2026, 7, 25) },		{			name: 'Mohammed Karim',			usdCredit: 553.99,			iqdCredit: 395629181,			moved: new Date(2026, 7, 20)		},		{ name: 'Mustafa Nouri', usdCredit: 101, iqdDebit: 871851, moved: new Date(2026, 7, 24) },		{ name: 'Omar Shakir', usdDebit: 106.5, iqdDebit: 720345, moved: new Date(2026, 7, 20) },		{ name: 'Yousif Tariq', usdCredit: 61, iqdDebit: 171966, moved: new Date(2026, 7, 20) },		{ name: 'Zaid Firas', usdCredit: 1001030.68, iqdCredit: 573400, moved: new Date(2026, 7, 14) },		{ name: 'Haider Adil', usdCredit: 590, iqdCredit: 5360630, moved: new Date(2026, 7, 16) },		{ name: 'Fatima Ali', usdCredit: 7705, iqdCredit: 388460, moved: new Date(2026, 7, 20) },		{ name: 'Ruqaya Shihab', usdDebit: 1274, iqdCredit: 12000000, moved: new Date(2026, 7, 24) }	];​	const COLUMNS: TableColumn<Account>[] = [		{ key: 'name', header: 'Name', sortable: true, width: '32%' },		{ key: 'usdDebit', header: 'Debit', group: 'USD', format: 'decimal', summary: 'sum' },		{ key: 'usdCredit', header: 'Credit', group: 'USD', format: 'decimal', summary: 'sum' },		{ key: 'iqdDebit', header: 'Debit', group: 'IQD', format: 'number', summary: 'sum' },		{ key: 'iqdCredit', header: 'Credit', group: 'IQD', format: 'number', summary: 'sum' },		{ key: 'moved', header: 'Last movement', format: 'date', sortable: true, hideBelow: 'sm' }	];​	const sum = (rows: Account[], key: keyof Account) =>		rows.reduce((total, row) => total + ((row[key] as number | undefined) ?? 0), 0);</script>​<Table columns={COLUMNS} rows={ROWS} rowNumbers fullscreen search label="Customer balances">	{#snippet foot({ rows, cell })}		<tr>			<td colspan={2} class={cell}>Net</td>			<td colspan={2} class={[cell, 'text-center tabular-nums']}>				<span dir="ltr"					>{formatCell('decimal', sum(rows, 'usdDebit') - sum(rows, 'usdCredit'))}</span				>			</td>			<td colspan={2} class={[cell, 'text-center tabular-nums']}>				<span dir="ltr">{formatCell('number', sum(rows, 'iqdDebit') - sum(rows, 'iqdCredit'))}</span				>			</td>			<td class={cell}></td>		</tr>	{/snippet}</Table>

Selection and pagination

selection adds the checkbox column and the bulk-action bar; pagination takes a page size. Both are bindable if you need the state outside.

FileSize
export-001.csv1.2 MB
export-002.csv1.6 MB
export-003.csv2.0 MB
export-004.csv2.4 MB
export-005.csv2.8 MB
<script lang="ts">	import { Button, Table } from 'omaris';​	const ROWS = [		{ id: 1, file: 'export-001.csv', size: '1.2 MB' },		{ id: 2, file: 'export-002.csv', size: '1.6 MB' },		{ id: 3, file: 'export-003.csv', size: '2.0 MB' },		{ id: 4, file: 'export-004.csv', size: '2.4 MB' },		{ id: 5, file: 'export-005.csv', size: '2.8 MB' },		{ id: 6, file: 'export-006.csv', size: '3.2 MB' },		{ id: 7, file: 'export-007.csv', size: '3.6 MB' },		{ id: 8, file: 'export-008.csv', size: '4.0 MB' },		{ id: 9, file: 'export-009.csv', size: '4.4 MB' },		{ id: 10, file: 'export-010.csv', size: '4.8 MB' },		{ id: 11, file: 'export-011.csv', size: '5.2 MB' },		{ id: 12, file: 'export-012.csv', size: '5.6 MB' },		{ id: 13, file: 'export-013.csv', size: '6.0 MB' },		{ id: 14, file: 'export-014.csv', size: '6.4 MB' }	];​	const COLUMNS = [		{ key: 'file', header: 'File' },		{ key: 'size', header: 'Size', numeric: true }	];</script>​<Table	columns={COLUMNS}	rows={ROWS}	rowKey={(row) => row.id}	selection	pagination={5}	label="Exports">	{#snippet selectionActions(keys)}		<Button variant="text" tone="destructive" size="xs">Delete {keys.length}</Button>	{/snippet}</Table>

Custom cells

cell takes over a column that is not text — a badge, a button, an avatar — and still sorts on value.

Deployed byAgo
storefront
Amina Yusuf Amina Yusuf
live3m
admin
Karwan Ali Karwan Ali
building0m
api
Hevi Salih Hevi Salih
failed41m
<script lang="ts" module>	export const meta = { fill: true };​	type Deploy = {		app: string;		who: string;		status: 'live' | 'building' | 'failed';		minutes: number;	};</script>​<script lang="ts">	import { Avatar, Badge, Table } from 'omaris';​	const ROWS: Deploy[] = [		{ app: 'storefront', who: 'Amina Yusuf', status: 'live', minutes: 3 },		{ app: 'admin', who: 'Karwan Ali', status: 'building', minutes: 0 },		{ app: 'api', who: 'Hevi Salih', status: 'failed', minutes: 41 }	];​	const TONES = { live: 'success', building: 'info', failed: 'destructive' } as const;</script>​{#snippet who(row: Deploy)}	<div class="flex items-center gap-2">		<Avatar name={row.who} size="sm" colorize />		<span>{row.who}</span>	</div>{/snippet}​{#snippet status(row: Deploy)}	<Badge tone={TONES[row.status]} variant="tonal" size="sm">{row.status}</Badge>{/snippet}​<Table	columns={[		{ key: 'app', header: 'App', sortable: true },		{ key: 'who', header: 'Deployed by', cell: who },		{ key: 'status', header: 'Status', cell: status, value: (row) => row.status, sortable: true },		{ key: 'minutes', header: 'Ago', numeric: true, accessor: (row) => `${row.minutes}m` }	]}	rows={ROWS}	label="Deploys"/>

States

Loading draws skeleton rows at the real row height, so nothing jumps.

NameCityTotal
NameCityTotal
No orders yet. They will show up here the moment one lands.
<script lang="ts">	import { Table } from 'omaris';​	const COLUMNS = [		{ key: 'name', header: 'Name' },		{ key: 'city', header: 'City' },		{ key: 'total', header: 'Total', numeric: true }	];</script>​<div class="flex w-full flex-col gap-6">	<Table columns={COLUMNS} rows={[]} loading loadingRows={3} label="Loading" />	<Table		columns={COLUMNS}		rows={[]}		emptyText="No orders yet. They will show up here the moment one lands."		label="Empty"	/></div>

Overridden

Every part of the table is reachable, which is how you get a house style without forking it — uppercase headers, zebra rows, a monospace id column.

InvoiceCustomerIQD
INV-4021Kurdistan Roasters480,000
INV-4022Dijla Grocers125,000
INV-4023Shatt Café62,500
<script lang="ts">	import { Table } from 'omaris';​	const ROWS = [		{ id: 'INV-4021', customer: 'Kurdistan Roasters', total: '480,000' },		{ id: 'INV-4022', customer: 'Dijla Grocers', total: '125,000' },		{ id: 'INV-4023', customer: 'Shatt Café', total: '62,500' }	];</script>​<Table	columns={[		{ key: 'id', header: 'Invoice', class: 'font-mono' },		{ key: 'customer', header: 'Customer' },		{ key: 'total', header: 'IQD', numeric: true }	]}	rows={ROWS}	variant="surface"	density="comfortable"	label="Invoices"	classes={{		headCell: 'uppercase tracking-wide text-xs',		row: 'even:bg-surface-container-low'	}}/>

Orders dashboard

The table a dashboard actually ships: a customer with an avatar, a status badge, money that lines up, a trend in every row, filters and search in the toolbar, a row menu, a bulk bar when rows are picked, a sticky header, and a sheet for the row you press. Every feature is a prop, and none of them is on until you ask.

Order
ORD-5122
Hevi Salih

Hevi Salih

hevi@shattcafe.com
refunded73,293
ORD-5121
Karwan Ali

Karwan Ali

karwan@roasters.iq
failed428,186
ORD-5120
Amina Yusuf

Amina Yusuf

amina@dijla.co
paid363,079
ORD-5125
Soran Aziz

Soran Aziz

soran@aziz.io
paid268,614
ORD-5124
Rania Hadi

Rania Hadi

rania@hadi.family
pending203,507
ORD-5123
Dilan Karim

Dilan Karim

dilan@zagros.dev
paid138,400
ORD-5128
Zhyan Rasul

Zhyan Rasul

zhyan@rasul.me
refunded43,935
ORD-5127
Omar Nuri

Omar Nuri

omar@nuri.co
failed398,828
ORD-5126
Lana Faraj

Lana Faraj

lana@faraj.studio
paid333,721
ORD-5131
Nour Salem

Nour Salem

nour@salem.co
paid239,256
<script lang="ts" module>	export const meta = { fill: true };​	type Status = 'paid' | 'pending' | 'refunded' | 'failed';​	type Order = {		id: string;		customer: string;		email: string;		status: Status;		channel: 'web' | 'app' | 'pos';		total: number;		items: number;		placed: Date;		trend: number[];	};</script>​<script lang="ts">	import { Sparkline } from 'omaris/chart';	import {		Avatar,		Badge,		Button,		IconButton,		Menu,		MenuItem,		Sheet,		Table,		Text,		latn,		type TableColumn	} from 'omaris';​	const NAMES = [		['Amina Yusuf', 'amina@dijla.co'],		['Karwan Ali', 'karwan@roasters.iq'],		['Hevi Salih', 'hevi@shattcafe.com'],		['Dilan Karim', 'dilan@zagros.dev'],		['Rania Hadi', 'rania@hadi.family'],		['Soran Aziz', 'soran@aziz.io'],		['Lana Faraj', 'lana@faraj.studio'],		['Omar Nuri', 'omar@nuri.co'],		['Zhyan Rasul', 'zhyan@rasul.me'],		['Bahar Qadir', 'bahar@qadir.shop'],		['Ari Hassan', 'ari@hassan.dev'],		['Nour Salem', 'nour@salem.co']	];	const STATUSES: Status[] = ['paid', 'paid', 'pending', 'paid', 'refunded', 'failed'];	const CHANNELS = ['web', 'app', 'pos'] as const;​	/** Deterministic, so the page looks the same on every load. */	const ORDERS: Order[] = Array.from({ length: 36 }, (_, i) => {		const [customer, email] = NAMES[i % NAMES.length];		const seed = (i * 9301 + 49297) % 233280;		const total = 18_000 + ((seed * 7) % 420_000);		return {			id: `ORD-${(5120 + i).toString()}`,			customer,			email,			status: STATUSES[(i * 5) % STATUSES.length],			channel: CHANNELS[(i * 7) % CHANNELS.length],			total,			items: 1 + ((seed >> 3) % 7),			placed: new Date(2026, 8, 4 - Math.floor(i / 3), 9 + (i % 9), (i * 17) % 60),			trend: Array.from({ length: 8 }, (_, j) => 20 + ((seed >> j) % 60) + j * (i % 3))		};	});​	const TONES: Record<Status, 'success' | 'warning' | 'info' | 'destructive'> = {		paid: 'success',		pending: 'warning',		refunded: 'info',		failed: 'destructive'	};​	const iqd = new Intl.NumberFormat(undefined, latn({ maximumFractionDigits: 0 }));	const when = new Intl.DateTimeFormat(		undefined,		latn({ day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })	);​	let rows = $state(ORDERS);	let open = $state<Order | null>(null);​	const COLUMNS: TableColumn<Order>[] = [		{			key: 'id',			header: 'Order',			class: 'font-mono text-label-md',			width: '7rem',			alwaysVisible: true		},		{ key: 'customer', header: 'Customer', cell: customer, sortable: true },		{ key: 'status', header: 'Status', cell: status, value: (row) => row.status, sortable: true },		{			key: 'channel',			header: 'Channel',			hideBelow: 'lg',			accessor: (row) => row.channel.toUpperCase()		},		{ key: 'items', header: 'Items', numeric: true, hideBelow: 'md', sortable: true },		{ key: 'trend', header: 'Last 8 wks', cell: trend, hideBelow: 'md', searchable: false },		{			key: 'total',			header: 'Total (IQD)',			numeric: true,			sortable: true,			accessor: (row) => iqd.format(row.total)		},		{			key: 'placed',			header: 'Placed',			hideBelow: 'sm',			sortable: true,			accessor: (row) => when.format(row.placed)		},		{ key: 'menu', header: '', cell: menu, width: '3rem', searchable: false, alwaysVisible: true }	];​	function remove(ids: (string | number)[]) {		rows = rows.filter((row) => !ids.includes(row.id));	}</script>​{#snippet customer(row: Order)}	<div class="flex items-center gap-2.5">		<Avatar name={row.customer} size="sm" colorize />		<div class="flex min-w-0 flex-col">			<Text variant="body-md" class="truncate">{row.customer}</Text>			<Text variant="label-sm" tone="muted" class="truncate">{row.email}</Text>		</div>	</div>{/snippet}​{#snippet status(row: Order)}	<Badge tone={TONES[row.status]} variant="tonal" size="sm">{row.status}</Badge>{/snippet}​{#snippet trend(row: Order)}	<Sparkline data={row.trend} width={88} height={24} area marker />{/snippet}​{#snippet menu(row: Order)}	<!-- The row opens the sheet on press; the menu must not. -->	<!-- svelte-ignore a11y_no_static_element_interactions -->	<span		class="flex justify-end"		onclick={(event) => event.stopPropagation()}		onkeydown={(event) => event.stopPropagation()}	>		<Menu label="Order actions" align="end">			{#snippet trigger(props)}				<IconButton {...props} size="sm" aria-label="Actions for {row.id}">					<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">						<circle cx="5" cy="12" r="2" /><circle cx="12" cy="12" r="2" /><circle							cx="19"							cy="12"							r="2"						/>					</svg>				</IconButton>			{/snippet}			<MenuItem onclick={() => (open = row)}>View details</MenuItem>			<MenuItem>Resend receipt</MenuItem>			<MenuItem tone="destructive" onclick={() => remove([row.id])}>Archive</MenuItem>		</Menu>	</span>{/snippet}​<Table	columns={COLUMNS}	{rows}	rowKey={(row) => row.id}	label="Orders"	sticky	maxHeight="26rem"	search={{ placeholder: 'Search orders, customers…' }}	filtering={[		{ key: 'status', label: 'Status' },		{ key: 'channel', label: 'Channel', format: (value) => value.toUpperCase() }	]}	sorting={{ key: 'placed', direction: 'desc' }}	pagination={{ size: 10, sizeOptions: [10, 20, 50] }}	selection	columnVisibility	onrowclick={(row) => (open = row)}	classes={{ row: 'cursor-pointer' }}>	{#snippet actions()}		<Button size="sm" variant="tonal">Export</Button>	{/snippet}	{#snippet selectionActions(keys)}		<Button size="xs" variant="text">Mark paid</Button>		<Button size="xs" variant="text" tone="destructive" onclick={() => remove(keys)}>			Archive {keys.length}		</Button>	{/snippet}</Table>​<Sheet	open={open !== null}	onclose={() => (open = null)}	title={open?.id}	description={open ? `${open.customer} · ${when.format(open.placed)}` : undefined}>	{#if open}		<div class="flex flex-col gap-4 px-5 pb-5">			<div class="grid grid-cols-2 gap-3">				<div class="rounded-shape-md bg-surface-container p-3">					<Text variant="label-sm" tone="muted">Total</Text>					<Text variant="title-md" class="tabular-nums">{iqd.format(open.total)} IQD</Text>				</div>				<div class="rounded-shape-md bg-surface-container p-3">					<Text variant="label-sm" tone="muted">Status</Text>					<div class="mt-1">						<Badge tone={TONES[open.status]} variant="tonal">{open.status}</Badge>					</div>				</div>			</div>			<div class="flex flex-col gap-1">				<Text variant="label-sm" tone="muted">Weekly orders from this customer</Text>				<Sparkline data={open.trend} width="100%" height={56} area marker />			</div>			<Text variant="body-sm" tone="muted">				{open.items} item{open.items === 1 ? '' : 's'} through the {open.channel} channel.			</Text>		</div>	{/if}	{#snippet footer()}		<Button variant="text" onclick={() => (open = null)}>Close</Button>		<Button>Resend receipt</Button>	{/snippet}</Sheet>

Server side

When the rows live on a server, manual on a feature hands that stage back to you: the table renders what it is given and reports what the user asked for. This one fakes the round trip with a delay, so the loading skeletons and the page count out of total are the real thing.

#
0 requests · page 1 of 1 · 0 matching
<script lang="ts" module>	export const meta = { fill: true };​	type Ticket = {		id: number;		subject: string;		requester: string;		priority: 'low' | 'normal' | 'high';		age: number;	};</script>​<script lang="ts">	import { untrack } from 'svelte';	import { Badge, Table, Text, type TableColumn } from 'omaris';​	/** The whole "database": 240 tickets. */	const SUBJECTS = [		'Card declined at checkout',		'Cannot reset password',		'Invoice shows wrong tax',		'Dark mode chart labels unreadable',		'Export is missing rows',		'Webhook retries forever',		'Two-factor code never arrives',		'Wrong currency on receipt'	];	const PEOPLE = ['Amina', 'Karwan', 'Hevi', 'Dilan', 'Rania', 'Soran', 'Lana', 'Omar'];	const DB: Ticket[] = Array.from({ length: 240 }, (_, i) => ({		id: 9000 + i,		subject: SUBJECTS[(i * 3) % SUBJECTS.length],		requester: PEOPLE[(i * 5) % PEOPLE.length],		priority: (['low', 'normal', 'normal', 'high'] as const)[(i * 7) % 4],		age: (i * 13) % 96	}));​	type Query = { page: number; size: number; key?: string; direction?: 'asc' | 'desc'; q: string };​	let query = $state<Query>({ page: 1, size: 8, key: 'age', direction: 'asc', q: '' });	let rows = $state<Ticket[]>([]);	let total = $state(0);	let loading = $state(true);	let requests = $state(0);	/** Only the newest round trip is allowed to land. */	let latest = 0;​	/** What a server would do: filter, sort, slice — after a delay. */	async function fetchPage(q: Query) {		const ticket = ++latest;		requests += 1;		loading = true;		await new Promise((resolve) => setTimeout(resolve, 550));		if (ticket !== latest) return;		let hits = DB.filter(			(t) => !q.q || `${t.subject} ${t.requester}`.toLowerCase().includes(q.q.toLowerCase())		);		if (q.key) {			const key = q.key as keyof Ticket;			const sign = q.direction === 'desc' ? -1 : 1;			hits = [...hits].sort((a, b) => (a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0) * sign);		}		total = hits.length;		rows = hits.slice((q.page - 1) * q.size, q.page * q.size);		loading = false;	}​	/**	 * The query is the dependency; the fetch is not. `untrack` is load-bearing	 * — `fetchPage` reads `requests` to bump it, synchronously, and an effect	 * that reads the state it writes re-runs itself until Svelte gives up with	 * `effect_update_depth_exceeded`. That error escapes hydration, so the	 * whole *page* stops being interactive, not just this demo.	 */	$effect(() => {		const q = { ...query };		untrack(() => void fetchPage(q));	});​	const COLUMNS: TableColumn<Ticket>[] = [		{ key: 'id', header: '#', width: '5rem', class: 'font-mono' },		{ key: 'subject', header: 'Subject', sortable: true },		{ key: 'requester', header: 'Requester', sortable: true, hideBelow: 'sm' },		{			key: 'priority',			header: 'Priority',			cell: priority,			value: (row) => row.priority,			sortable: true		},		{			key: 'age',			header: 'Open for',			numeric: true,			sortable: true,			accessor: (row) => `${row.age}h`		}	];</script>​{#snippet priority(row: Ticket)}	<Badge		size="sm"		variant={row.priority === 'high' ? 'filled' : 'tonal'}		tone={row.priority === 'high'			? 'destructive'			: row.priority === 'normal'				? 'primary'				: 'secondary'}	>		{row.priority}	</Badge>{/snippet}​<div class="flex w-full flex-col gap-2">	<Table		columns={COLUMNS}		{rows}		rowKey={(row) => row.id}		label="Tickets"		{loading}		loadingRows={query.size}		search={{			manual: true,			placeholder: 'Search subject or requester',			onchange: (q) => (query = { ...query, q, page: 1 })		}}		sorting={{			manual: true,			key: query.key,			direction: query.direction,			onchange: ({ key, direction }) => (query = { ...query, key, direction, page: 1 })		}}		pagination={{			manual: true,			page: query.page,			size: query.size,			total,			sizeOptions: [8, 16, 32],			onchange: ({ page, size }) => (query = { ...query, page, size })		}}	/>	<Text variant="label-sm" tone="muted" class="tabular-nums">		{requests} request{requests === 1 ? '' : 's'} · page {query.page} of {Math.max(			1,			Math.ceil(total / query.size)		)} · {total} matching	</Text></div>

When to use it

Use it for

  • Figures people scan like a spreadsheet. The default variant="grid" rules every cell on a white page, framed by a tinted header and footer; group spans a header over sibling columns (USD over Debit and Credit), summary adds a totals row, rowNumbers the # column, fullscreen a button that gives the sheet the whole screen.
  • A quick look at any array. <Table {rows} /> reads the columns off the first row; format: 'number' | 'decimal' | 'percent' | 'date' or any Intl options formats a column in Western digits.
  • Rows people read across and compare. Pass columns and rows; each feature is one prop: search, selection, pagination={20}, sortable on a column, filtering as chips on one line.
  • Rows that live on a server. manual on sorting, filtering or pagination hands that stage back: the table reports through that feature's onchange, renders what it is given, and pagination.total is the count.
  • A dashboard's main list: an Avatar for the customer, a Badge for status, aligned money, a Sparkline per row, a row menu, a bulk bar through selectionActions once rows are picked, and sticky with maxHeight.
  • Loading and empty states. loading draws skeleton rows at the real row height; the empty snippet puts an Empty inside the frame.

Not for

  • Rows of one to three lines with a leading avatar or icon, especially on a phone → List. It has swipe actions and no header row.
  • Items whose picture is the point → a Card grid or Masonry.
  • Things that move between stages → Kanban.
  • A few key–value pairs, like a settings summary → Text as dt and dd.
  • Cells people edit in place → nothing here. Cells are read-only; cell only changes how a column renders.

Do

  • Keep the grid default for money, ledgers and anything read cell by cell. outlined for a list of people or things with avatars and badges, where rules between columns are noise.
  • Put totals in summary rather than a row in rows: it follows the search and filters and never sorts into the middle. Extra footer rows go in the foot snippet, with the cell class it hands you.
  • Keep density="compact", the default, in a dashboard. comfortable for rows people tap on a tablet; spacious for a few rows of prose.
  • Give a column value so it sorts and searches on the number behind the display, and cell to take over the markup without losing that.
  • Set hideBelow on columns a phone does not need. The toolbar already reflows into search, then filters, then the pager.
  • Set rowKey to a stable id so a selection survives a re-sort, and label so the table has an accessible name.
  • Mark at most one filter inline: the one worth a single press.

Don't

  • Hold search, sort and page in $state unless the page needs to know. Every feature is uncontrolled by default and bindable when needed.
  • Make every column sortable. It hides which ones matter.
  • Add a second empty state under the table. The empty snippet is already inside the frame.
  • Let the page scroll sideways. The viewport scrolls, and hideBelow drops what will not fit.

Quick reference

variant
  • plain
  • surface
  • outlined
  • grid (default)
density
  • compact (default)
  • comfortable
  • spacious

API

Table

Data table.

Driven by a columns array rather than markup, because that's what a dashboard table actually is — the same header/cell/alignment decisions repeated per column. Each column can still take over its own cell with a snippet, so the data-driven path never becomes a ceiling.

Everything above and below the rows is opt-in, comes wired up, and is one prop rather than a run of four. Passing the prop is what turns the feature on:

That is a table with a working search box, a checkbox column and a pager, holding all three states itself — no $state and no $derived in your page. Mark a column sortable and it sorts too.

Each feature takes an object when you need more from it, always in the same shape: the current state, manual if the caller handles that stage, and one onchange.

Filters keep the toolbar to one line however many of them there are: each is a chip carrying what you picked — "Status · paid" — with its options behind a menu, and every option carries the count it would leave you with. Past the width of the bar the chips scroll sideways rather than wrapping into a second and third row. A filter marked inline puts its options in the bar as toggles instead, for the one filter on a screen worth a press rather than two.

When the data lives on a server, manual on a feature hands that stage back — the table reports what was asked for through that feature's own onchange and renders the rows you give it. pagination.total is the row count behind the page.

Every feature is uncontrolled by default and controllable with bind: or onchange — reach for either only when the page needs to know.

It looks like a spreadsheet by default — variant="grid", every cell ruled, a white page for the rows inside a tinted frame — and reads like one when asked: group spans a header over neighbouring columns, summary totals a column, rowNumbers numbers the rows, fullscreen lets it take the screen.

And <Table {rows} /> with nothing else reads its columns off the rows.

import { Table } from 'omaris'
<Table {columns} {rows} search selection pagination={20} />
<Table {columns} {rows}  search={{ placeholder: 'Find an order…' }}  pagination={{ size: 20, sizeOptions: [10, 20, 50] }}  selection={{ onchange: (keys) => (picked = keys) }}/>
<Table {columns} rows={page.orders}  sorting={{ manual: true, onchange: load }}  pagination={{ manual: true, total: page.count, onchange: load }}/>
<Table rows={accounts} rowNumbers fullscreen columns={[  { key: 'name', header: 'Name' },  { key: 'usdDebit', header: 'Debit', group: 'USD', format: 'decimal', summary: 'sum' },  { key: 'usdCredit', header: 'Credit', group: 'USD', format: 'decimal', summary: 'sum' }]} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
toolbar
Search, filters and actions, above the header.
search
The search box. Its own line on a phone, beside the filters above one.
filterBar
The filter chips. One line that scrolls sideways rather than a block that wraps — a toolbar that grows a second and third row as filters are added is the thing this bar is built not to do. The strip fades at whichever edge it continues past, so a filter scrolled out of the bar still says that it is there — data-fade names one edge at a time, so exactly one of these ever applies.
toolbarActions
The trailing edge: the selection count, the column menu, actions.
scroller
Scroll container — a wide table scrolls, the page doesn't.
table
No description in the source yet.
head
No description in the source yet.
headRow
No description in the source yet.
headCell
No description in the source yet.
groupCell
The spanning header over a run of columns that share a group.
sorter
The sort control inside a sortable header cell.
body
No description in the source yet.
row
No description in the source yet.
cell
No description in the source yet.
pick
The checkbox column, when rows are selectable.
rowNumber
The # column, when rowNumbers is on.
empty
The full-width message shown in place of rows.
foot
The summary row, and any rows the foot snippet adds.
footCell
A cell of the summary row. The foot snippet is handed this too.
footer
Pagination and the row count, under the table.
caption
No description in the source yet.

Props

columns
TableColumn<T>[]

What to show and how. Leave it out and the columns are read off the first row — every field, a readable header, all sortable.

rows required
T[]
variant

Defaults to 'grid'

TableVariant
plain
surface
outlined
A clean white page for the rows, framed by a tinted header and footer — the eye finds where the data starts without a rule.
grid
The spreadsheet: every cell ruled, the way a sheet reads — you follow a figure down its column and across its row without losing the line. Separate borders rather than collapsed, so a sticky header carries its rules with it as the body scrolls.
density

Defaults to 'compact'

TableDensity
compact
comfortable
spacious
striped

Defaults to false

boolean
hoverable

Defaults to true

boolean
sticky

Defaults to false

boolean

Pin the header. Pair with maxHeight.

maxHeight

Defaults to '32rem'

string

Height of the scroll area when sticky. Any CSS length.

rowKey
(row: T, index: number) => Key

Stable key per row. Falls back to the index.

sorting bindable

Defaults to undefined

TableSorting

Current sort. Columns opt in with sortable, so this is state rather than a switch — leave it off and sortable columns still sort, the table just keeps the sort itself.

search bindable

Defaults to undefined

boolean | TableSearch

The search box. Passing it at all is what puts it in the toolbar.

filtering bindable

Defaults to undefined

TableFilter<T>[] | TableFiltering<T>

The filter bar. An array is the filters; the object form adds the picked state and an onchange. Options are derived from the rows when a filter doesn't list its own.

pagination bindable

Defaults to undefined

boolean | number | TablePagination

Pagination. true takes the default page size, a number is the page size, and the object form carries the page, the rows-per-page choices and the server-side total.

selection bindable

Defaults to undefined

boolean | TableSelection<Key>

Row selection. Passing it at all is what adds the checkbox column.

columnVisibility bindable

Defaults to undefined

boolean | TableColumnVisibility

The column-visibility menu. Passing it at all is what adds it.

fullscreen bindable

Defaults to undefined

boolean | TableFullscreen

A button that lets the table fill the screen, header pinned, for the moment a wide sheet needs every pixel. Escape comes back.

rowNumbers

Defaults to false

boolean

A leading # column numbering the rows, the way a sheet does.

summaryLabel

Defaults to 'Total'

string

Label of the summary row the columns' summary builds.

foot
Snippet<[{ rows: T[]; cell: string; span: number }]>

Extra rows under the summary — a net figure spanning two columns, say. Write <tr>s; cell is the class a footer cell takes, rows what the search and filters left, span the column count.

onrowclick
(row: T, index: number) => void

Called when a row is pressed. Makes rows interactive.

loading

Defaults to false

boolean

Draw skeletons while the data is on its way.

loadingRows

Defaults to 5

number
emptyText

Defaults to 'Nothing here yet'

string

Message when there are no rows at all.

noResultsText

Defaults to 'No rows match your filters'

string

Message when the filters or the search excluded everything.

label
string

Accessible name for the table.

caption
string

Visible caption under the table.

class
string
classes
TableClasses

Per-part Tailwind overrides. class still covers the root.

actions
Snippet

Extra controls on the trailing edge of the toolbar.

selectionActions
Snippet<[Key[]]>

Replaces the toolbar's trailing edge while rows are selected.

empty
Snippet

Rich empty state, in place of emptyText.

TableFilterControl

One filter in a table's toolbar.

The default is a single chip with a menu behind it, because that is what keeps a toolbar to one line: every filter is the same object whatever its option list looks like, and the chip carries what you picked — "Status · paid", "Status · 2" — so the bar still reads as a summary of the query rather than a wall of toggles.

A filter marked inline lays its options out as toggle chips instead, for the one filter on a screen that is worth a press rather than two. Beyond a handful of options that trade stops paying, so the toggles fold back into the menu on their own.

Picked, the chip's chevron becomes a × — clearing one filter never means opening it first.

import { TableFilterControl } from 'omaris'

Props

label required
string
options required
TableFilterOption[]
selected required bindable

Defaults to []

string[]

Bindable.

single

Defaults to false

boolean
inline
boolean

Lay the options out as toggle chips rather than behind a menu.

format
(value: string) => string
onchange
(selected: string[]) => void