Data table
Rows with sortable headers, selection that survives a sort, and its own loading and empty states.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-data-table.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-data-table.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-data-table.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-data-table.jsonui/table is markup: eight elements that draw a grid and know nothing. Everything that makes a
table a table gets written again per screen - which header is sorted and which way, what the arrow
means on the third press, a header checkbox that has to say "some of them", a Shift-click that takes
a range, skeleton rows that keep the layout still, and an empty state that spans the columns instead
of hiding in the first one.
| Açaí | Amara Diallo | ready | 42s | |
| Kiwi | Wei Zhang | failed | — | |
| Mango | Sofia Rossi | building | 9s | |
| Papaya | Liam O'Connor | ready | 128s |
Sorted by at, desc. A third press on the same header puts the rows back the way they arrived.
<script lang="ts">
import DataTable from '#lib/components/coral/kit/data-table/data-table.svelte';
import RelativeTime from '#lib/components/coral/kit/relative-time/relative-time.svelte';
import { sortRows } from '#lib/components/coral/lib/table.js';
import { Badge } from '#lib/components/ui/badge/index.js';
import type { Column, Sort } from '#lib/components/coral/kit/data-table/types.js';
type Deploy = {
id: string;
project: string;
author: string;
status: 'ready' | 'building' | 'failed';
duration: number | null;
at: Date;
};
const minute = 60_000;
const now = Date.now();
const deploys: Deploy[] = [
{
id: '1',
project: 'Açaí',
author: 'Amara Diallo',
status: 'ready',
duration: 42,
at: new Date(now - 3 * minute)
},
{
id: '2',
project: 'Kiwi',
author: 'Wei Zhang',
status: 'failed',
duration: null,
at: new Date(now - 26 * minute)
},
{
id: '3',
project: 'Mango',
author: 'Sofia Rossi',
status: 'building',
duration: 9,
at: new Date(now - 90 * minute)
},
{
id: '4',
project: 'Papaya',
author: "Liam O'Connor",
status: 'ready',
duration: 128,
at: new Date(now - 5 * 60 * minute)
}
];
let sort = $state<Sort>({ column: 'at', direction: 'desc' });
// Sorting is the caller's: the same table serves a page that sorts its own array and a server
// that sorts for it.
const rows = $derived(
sortRows(deploys, sort, { value: (row, column) => row[column as keyof Deploy] })
);
const columns: Column<Deploy>[] = [
{ id: 'project', header: 'Project', value: (row) => row.project, sortable: true },
{ id: 'author', header: 'Author', value: (row) => row.author, sortable: true },
{ id: 'status', header: 'Status', value: (row) => row.status, sortable: true, cell: status },
{
id: 'duration',
header: 'Duration',
value: (row) => row.duration,
sortable: true,
align: 'end',
cell: duration
},
{ id: 'at', header: 'Deployed', value: (row) => row.at, sortable: true, cell: deployed }
];
</script>
{#snippet status({ row }: { row: Deploy })}
<Badge variant={row.status === 'failed' ? 'destructive' : 'secondary'}>{row.status}</Badge>
{/snippet}
{#snippet duration({ row }: { row: Deploy })}
{row.duration === null ? '—' : `${row.duration}s`}
{/snippet}
{#snippet deployed({ row }: { row: Deploy })}
<RelativeTime date={row.at} class="text-muted-foreground" />
{/snippet}
<div class="w-full max-w-3xl">
<DataTable {rows} {columns} getRowId={(row) => row.id} bind:sort caption="Recent deploys" />
<p class="mt-3 text-sm text-muted-foreground">
Sorted by {sort?.column ?? 'nothing'}{sort ? `, ${sort.direction}` : ''}. A third press on the
same header puts the rows back the way they arrived.
</p>
</div>What Coral adds
- Three-state sorting. Ascending, descending, then back to the order the rows arrived in, which
is usually the order the server chose. The header cell carries
aria-sort, and the control inside it is a real button. - Selection that survives. Rows are remembered by id, so sorting, filtering or reloading does not move the selection onto different rows. Shift takes a range from the last row picked.
- A header checkbox that tells the truth. Checked, mixed, or empty, read against the rows on screen. Pressing it while mixed completes the selection, the way a tri-state checkbox behaves natively; the next press clears it.
- Loading that does not jump. Placeholder rows keep the column widths the real rows are about to
have, and are hidden from assistive tech, which hears
aria-busyinstead. - An empty state in the table. Spanning every column, only once nothing is loading.
- Rows you can click, when that means something. Pass
onrowactivateand rows become reachable and pressable with Enter; a press that landed on a checkbox, a link or a row action does not fire it as well.
Who sorts the rows
The table draws what it is handed, and reports what the reader pressed. That one decision is what
lets the same component serve a page holding its own array and a screen whose server does the
sorting: one passes the rows through sortRows, the other puts the sort in a query.
<script lang="ts">
import { sortRows } from '#lib/components/coral/lib/table.js';
let sort = $state({ column: 'project', direction: 'asc' });
const rows = $derived(sortRows(deploys, sort, { value: (row, column) => row[column] }));
</script>
<DataTable {rows} {columns} getRowId={(row) => row.id} bind:sort />Selection, search and a bulk action
| Key | Project | Scope | Owner | |
|---|---|---|---|---|
| CI pipeline | Açaí | deploy | Amara Diallo | |
| Staging seed | Kiwi | read | Wei Zhang | |
| Metrics export | Mango | read | Priya Sharma | |
| Webhook relay | Papaya | write | Kenji Tanaka |
Shift-click a second row to take the range. Search matches every word against the whole row and
folds accents, so acai finds Açaí - the same rule the combobox uses.
<script lang="ts">
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import ConfirmDialog from '#lib/components/coral/kit/confirm-dialog/confirm-dialog.svelte';
import DataTable from '#lib/components/coral/kit/data-table/data-table.svelte';
import SearchInput from '#lib/components/coral/kit/search-input/search-input.svelte';
import { filterRows } from '#lib/components/coral/lib/table.js';
import { Button } from '#lib/components/ui/button/index.js';
import type { Column } from '#lib/components/coral/kit/data-table/types.js';
type Key = { id: string; label: string; project: string; scope: string; owner: string };
let keys = $state<Key[]>([
{ id: '1', label: 'CI pipeline', project: 'Açaí', scope: 'deploy', owner: 'Amara Diallo' },
{ id: '2', label: 'Staging seed', project: 'Kiwi', scope: 'read', owner: 'Wei Zhang' },
{ id: '3', label: 'Metrics export', project: 'Mango', scope: 'read', owner: 'Priya Sharma' },
{ id: '4', label: 'Webhook relay', project: 'Papaya', scope: 'write', owner: 'Kenji Tanaka' }
]);
let term = $state('');
let selected = $state<string[]>([]);
const rows = $derived(
filterRows(keys, term, (row) => [row.label, row.project, row.scope, row.owner])
);
const columns: Column<Key>[] = [
{ id: 'label', header: 'Key', value: (row) => row.label },
{ id: 'project', header: 'Project', value: (row) => row.project },
{ id: 'scope', header: 'Scope', value: (row) => row.scope },
{ id: 'owner', header: 'Owner', value: (row) => row.owner }
];
function revoke() {
keys = keys.filter((key) => !selected.includes(key.id));
selected = [];
}
</script>
<div class="flex w-full max-w-3xl flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<SearchInput
bind:value={term}
onsearch={(next) => (term = next)}
placeholder="Search keys"
aria-label="Search keys"
class="max-w-64"
/>
{#if selected.length > 0}
<ConfirmDialog
title="Revoke {selected.length} key{selected.length === 1 ? '' : 's'}?"
description="Anything still using them will start failing immediately."
confirmLabel="Revoke"
variant="destructive"
onconfirm={revoke}
>
{#snippet trigger({ props })}
<Button {...props} variant="outline" size="sm">
<Trash2Icon data-icon="inline-start" />
Revoke ({selected.length})
</Button>
{/snippet}
</ConfirmDialog>
{/if}
</div>
<DataTable
{rows}
{columns}
getRowId={(row) => row.id}
selection="multiple"
bind:selected
caption="API keys"
emptyMessage="No key matches that search."
selectRowLabel={(row) => `Select ${row.label}`}
/>
<p class="text-sm text-muted-foreground">
Shift-click a second row to take the range. Search matches every word against the whole row and
folds accents, so <code>acai</code> finds Açaí - the same rule the combobox uses.
</p>
</div>filterRows matches every word of the term against the whole row, in any order and any field, and
folds accents through the same lib/fold the combobox searches with. Paging
is in the same module: paginate, pageCount and a clampPage that holds a page past the end at
the last one instead of showing an empty screen.
Loading and empty
| Role | |
|---|---|
No pending invites. | |
<script lang="ts">
import DataTable from '#lib/components/coral/kit/data-table/data-table.svelte';
import { Button } from '#lib/components/ui/button/index.js';
import type { Column } from '#lib/components/coral/kit/data-table/types.js';
type Invite = { id: string; email: string; role: string };
const invites: Invite[] = [
{ id: '1', email: 'elena@example.com', role: 'Admin' },
{ id: '2', email: 'lucas@example.com', role: 'Member' }
];
const columns: Column<Invite>[] = [
{ id: 'email', header: 'Email', value: (row) => row.email },
{ id: 'role', header: 'Role', value: (row) => row.role }
];
let rows = $state<Invite[]>([]);
let loading = $state(false);
async function load() {
loading = true;
rows = [];
await new Promise((resolve) => setTimeout(resolve, 1200));
rows = invites;
loading = false;
}
</script>
<div class="flex w-full max-w-2xl flex-col gap-3">
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={load}>Load</Button>
<Button variant="outline" size="sm" onclick={() => (rows = [])} disabled={loading}>
Clear
</Button>
</div>
<DataTable
{rows}
{columns}
{loading}
loadingRows={2}
getRowId={(row) => row.id}
caption="Pending invites"
emptyMessage="No pending invites."
/>
</div>Columns
type Column<T> = {
id: string; // names the column in `sort` and wherever a cell is addressed
header?: string | Snippet; // defaults to the id
value?: (row: T) => string | number | boolean | Date | null | undefined;
cell?: Snippet<[{ row: T; index: number }]>; // renders it; without one, `value` is shown as text
sortable?: boolean;
align?: 'start' | 'center' | 'end';
class?: string;
};value is what the column is: what it shows, what it sorts by, and what a search matches. cell is how it looks. A column that holds a date should have both - the date for sorting, a snippet to
render it - because a Date printed as text is nobody's idea of a date.
Import
<script lang="ts">
import DataTable from '#lib/components/coral/kit/data-table/data-table.svelte';
</script>Props
Everything a <div> accepts stays available on the root. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
rows | readonly T[] | - | The rows to draw, already sorted and paged. Required. |
columns | readonly Column<T>[] | - | The columns, in order. Required. |
getRowId | (row: T) => string | - | A row's identity, stable across sorts. Required. |
sort | Sort | - | Bindable. { column, direction }. |
onsortchange | (sort: Sort | undefined) => void | - | A header was pressed. Never on mount. |
selection | 'none' | 'single' | 'multiple' | 'none' | Whether rows can be picked. |
selected | string[] | [] | Bindable. Ids of the selected rows. |
onselectionchange | (selected: string[]) => void | - | The selection changed. |
onrowactivate | (row: T) => void | - | A row was clicked or activated with Enter. |
loading | boolean | false | Draws placeholder rows. |
loadingRows | number | 5 | How many. |
emptyMessage | string | Nothing to show. | Shown when there are no rows. |
caption | string | - | Names the table. Rendered visually hidden. |
stickyHeader | boolean | false | Keeps the header in view while the body scrolls. |
selectAllLabel | string | Select all rows | Accessible label for the header checkbox. |
selectRowLabel | (row: T) => string | Select row | Accessible label for a row's checkbox. |
class | string | - | Merged onto the root. |
rowClass | string | - | Merged onto every body row. |
empty | Snippet | - | Replaces the empty state. |
footer | Snippet | - | Rendered below the table - pagination, a count, a bulk bar. |
Accessibility
A real <table> with a real header, so assistive tech announces rows and columns and the caption
names the whole thing. Sortable headers carry aria-sort on the cell and a button inside it; the
selection checkboxes are labelled per row, because "Select" repeated eight times names nothing.
Placeholder rows are aria-hidden and the region is aria-busy while they show.
What is deliberately not here
Pagination controls, column visibility, filters and a toolbar. They are the difference between a
table and a table screen, they are where a project's own layout shows up, and they are the first
thing a blocks/ composition will put around this. footer is the seam meanwhile.
lib/table
Every rule above is a plain function, exported on its own and tested as such - so a list that is not a table can sort and filter exactly the way the table does:
import { filterRows, nextSort, paginate, sortRows } from '#lib/components/coral/lib/table.js';
nextSort({ column: 'name', direction: 'desc' }, 'name'); // undefined - back to no sort
sortRows(rows, sort, { value }); // stable, blanks last in both directions
filterRows(rows, 'acai ready', (row) => [row.project, row.status]);
paginate(rows, 7, 10); // a page past the end gives the last one