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.
<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.
<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> 3. Sorting and search
One prop per feature. sortable on a column shows its sort control; search adds the toolbar field and searches every column that has not opted out.
<script lang="ts"> import { Table } from 'omaris'; type Person = { name: string; role: string; city: string; commits: number }; const ROWS: Person[] = [ { name: 'Amina Yusuf', role: 'Engineer', city: 'Erbil', commits: 812 }, { name: 'Dilan Karim', role: 'Designer', city: 'Baghdad', commits: 143 }, { name: 'Hevi Salih', role: 'Engineer', city: 'Duhok', commits: 967 }, { name: 'Karwan Ali', role: 'Support', city: 'Basra', commits: 54 }, { name: 'Rania Hadi', role: 'Engineer', city: 'Erbil', commits: 401 } ]; const COLUMNS = [ { key: 'name', header: 'Name', sortable: true }, { key: 'role', header: 'Role', sortable: true }, { key: 'city', header: 'City', sortable: true, hideBelow: 'sm' as const }, { key: 'commits', header: 'Commits', numeric: true, sortable: true } ];</script><Table columns={COLUMNS} rows={ROWS} search label="Team" /> 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.
<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.
<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.
<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.
<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.
<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.
<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>