Skip to content
Data Display

Table

Data table.

Walkthrough

1. 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" />

2. 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>

4. 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>

5. 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"/>

6. 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>

7. 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'	}}/>

8. 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>

9. 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>