Data Display
Table
Data table.
import { Table } from 'omaris' 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.
<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.
<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> 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" /> 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> 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"/> 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> 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' }}/> 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> 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> 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;groupspans a header over sibling columns (USD over Debit and Credit),summaryadds a totals row,rowNumbersthe#column,fullscreena 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 anyIntloptions formats a column in Western digits. - Rows people read across and compare. Pass
columnsandrows; each feature is one prop:search,selection,pagination={20},sortableon a column,filteringas chips on one line. - Rows that live on a server.
manualonsorting,filteringorpaginationhands that stage back: the table reports through that feature'sonchange, renders what it is given, andpagination.totalis 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
selectionActionsonce rows are picked, andstickywithmaxHeight. - Loading and empty states.
loadingdraws skeleton rows at the real row height; theemptysnippet 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
dtanddd. - Cells people edit in place → nothing here. Cells are read-only;
cellonly changes how a column renders.
Do
- Keep the
griddefault for money, ledgers and anything read cell by cell.outlinedfor a list of people or things with avatars and badges, where rules between columns are noise. - Put totals in
summaryrather than a row inrows: it follows the search and filters and never sorts into the middle. Extra footer rows go in thefootsnippet, with thecellclass it hands you. - Keep
density="compact", the default, in a dashboard.comfortablefor rows people tap on a tablet;spaciousfor a few rows of prose. - Give a column
valueso it sorts and searches on the number behind the display, andcellto take over the markup without losing that. - Set
hideBelowon columns a phone does not need. The toolbar already reflows into search, then filters, then the pager. - Set
rowKeyto a stable id so a selection survives a re-sort, andlabelso 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
$stateunless 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
emptysnippet is already inside the frame. - Let the page scroll sideways. The viewport scrolls, and
hideBelowdrops what will not fit.
Quick reference
variant plainsurfaceoutlinedgrid(default)
density compact(default)comfortablespacious
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-fadenames 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, whenrowNumbersis on. empty- The full-width message shown in place of rows.
foot- The summary row, and any rows the
footsnippet adds. footCell- A cell of the summary row. The
footsnippet 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 plainsurfaceoutlined- 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 compactcomfortablespacious
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 bindableDefaults 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 bindableDefaults to undefined
boolean | TableSearch The search box. Passing it at all is what puts it in the toolbar.
filtering bindableDefaults 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 bindableDefaults 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 bindableDefaults to undefined
boolean | TableSelection<Key> Row selection. Passing it at all is what adds the checkbox column.
columnVisibility bindableDefaults to undefined
boolean | TableColumnVisibility The column-visibility menu. Passing it at all is what adds it.
fullscreen bindableDefaults 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 bindableDefaults 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