Compose data displays
Start from the question the user needs to answer. Keep data acquisition and transformations outside the display control, and choose an explicit loading, error, empty, or populated branch.
Select by task
| Task |
Compose |
Decision |
| Compare flat records |
Table |
Typed columns, formatters, or cell snippets. |
| Search and browse records |
Input + Table + Pagination |
Caller filters, slices, and controls page state. |
| Explain an empty result |
EmptyState |
Show only after loading finishes; compose a recovery action. |
| Group related content |
Card |
Supply padding, headings, and actions yourself. |
| Show hierarchy |
Tree |
Use hierarchical data instead of flattening relationships into a table. |
| Explain events over time |
Timeline |
Prefer an event narrative to a record grid. |
| Show trends or distribution |
Charts, Heatmap |
Select the appropriate data shape and provide a textual alternative. |
| Show bounded completion |
ProgressRing, MeterGroup |
Check value/range contracts; these are not data loaders. |
Setup
Use Svelte 5 in SvelteKit, with the theme and Tailwind 4 dist scan from Getting Started. Import the CSS in the root layout and wrap page content in Root. Table retrieves popup context at initialization, but only uses it for the column settings menu. Root (or a manual popup provider/container) is required when columnsEditable is enabled; a plain Table with column editing disabled can render without it.
Recipe: filter and page a local table
Complete page component beneath Root. The small page size makes pagination visible without a backend.
<script lang="ts">
import { Card, EmptyState, Field, Input, Pagination, Table, type ColumnDef } from '@atom-forge/ui';
import { Search } from 'lucide-svelte';
type Person = { id: number; name: string; role: string };
const searchId = $props.id();
let people = $state<Person[]>([
{ id: 1, name: 'Alice', role: 'Admin' },
{ id: 2, name: 'Bob', role: 'Editor' },
{ id: 3, name: 'Carol', role: 'Reader' },
]);
const columns: ColumnDef<Person>[] = [
{ key: 'name', label: 'Name', grow: true },
{ key: 'role', label: 'Role', width: '10rem' },
];
let query = $state('');
let page = $state(1);
const pageSize = 2;
const filtered = $derived(people.filter(person => person.name.toLowerCase().includes(query.toLowerCase())));
const total = $derived(Math.max(1, Math.ceil(filtered.length / pageSize)));
const currentPage = $derived(Math.min(Math.max(1, page), total));
const visible = $derived(filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize));
</script>
<Card class="p-4 flex flex-col gap-4">
<h2>People</h2>
<Field label="Search by name" for={searchId}>
<Input id={searchId} bind:value={query} oninput={() => page = 1}/>
</Field>
{#if filtered.length === 0}
<EmptyState icon={Search} title="No matching people" description="Try another name."/>
{:else}
<Table data={visible} {columns}/>
{#if total > 1}
<Pagination page={currentPage} {total} onchange={next => page = next}/>
{/if}
{/if}
<p role="status">{filtered.length} matching people</p>
</Card>
Filter before computing page count and slicing. Pagination.total is a page count, not a record count; pages start at 1 and onchange updates caller state. Reset on search input and clamp the effective page if the collection shrinks.
Application responsibilities
- Fetch data, distinguish loading from errors and empty results, implement retry, and cancel/ignore stale requests. The local array above deliberately has no network lifecycle.
- Implement sorting, selection, editing, filtering, pagination, and virtualization as needed. Table renders the supplied rows in their supplied order; it is not a data-grid engine.
- Own URL synchronization, server-side paging, permissions, formatting, and accessible alternatives for visualizations.
- Treat columns as initialization configuration: Table captures the initial array internally and fills missing
visible flags on its column objects. Later replacements require remounting; bind:columns currently does not report visibility-menu changes back to the caller.
- Do not combine
formatter with snippet, or grow with shrink, in a column. fixed protects visibility and affects no-scroll weighting; it does not create a sticky column.
- For custom row actions, use explicit accessible buttons/links. Table's
rowClick ignores interactive descendants, but caller-owned selection and navigation still need deliberate behavior.
Compose data displays
Start from the question the user needs to answer. Keep data acquisition and transformations outside the display control, and choose an explicit loading, error, empty, or populated branch.
Select by task
Setup
Use Svelte 5 in SvelteKit, with the theme and Tailwind 4
distscan from Getting Started. Import the CSS in the root layout and wrap page content inRoot. Table retrieves popup context at initialization, but only uses it for the column settings menu. Root (or a manual popup provider/container) is required whencolumnsEditableis enabled; a plain Table with column editing disabled can render without it.Recipe: filter and page a local table
Complete page component beneath Root. The small page size makes pagination visible without a backend.
<script lang="ts"> import { Card, EmptyState, Field, Input, Pagination, Table, type ColumnDef } from '@atom-forge/ui'; import { Search } from 'lucide-svelte'; type Person = { id: number; name: string; role: string }; const searchId = $props.id(); let people = $state<Person[]>([ { id: 1, name: 'Alice', role: 'Admin' }, { id: 2, name: 'Bob', role: 'Editor' }, { id: 3, name: 'Carol', role: 'Reader' }, ]); const columns: ColumnDef<Person>[] = [ { key: 'name', label: 'Name', grow: true }, { key: 'role', label: 'Role', width: '10rem' }, ]; let query = $state(''); let page = $state(1); const pageSize = 2; const filtered = $derived(people.filter(person => person.name.toLowerCase().includes(query.toLowerCase()))); const total = $derived(Math.max(1, Math.ceil(filtered.length / pageSize))); const currentPage = $derived(Math.min(Math.max(1, page), total)); const visible = $derived(filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize)); </script> <Card class="p-4 flex flex-col gap-4"> <h2>People</h2> <Field label="Search by name" for={searchId}> <Input id={searchId} bind:value={query} oninput={() => page = 1}/> </Field> {#if filtered.length === 0} <EmptyState icon={Search} title="No matching people" description="Try another name."/> {:else} <Table data={visible} {columns}/> {#if total > 1} <Pagination page={currentPage} {total} onchange={next => page = next}/> {/if} {/if} <p role="status">{filtered.length} matching people</p> </Card>Filter before computing page count and slicing.
Pagination.totalis a page count, not a record count; pages start at 1 andonchangeupdates caller state. Reset on search input and clamp the effective page if the collection shrinks.Application responsibilities
visibleflags on its column objects. Later replacements require remounting;bind:columnscurrently does not report visibility-menu changes back to the caller.formatterwithsnippet, orgrowwithshrink, in a column.fixedprotects visibility and affects no-scroll weighting; it does not create a sticky column.rowClickignores interactive descendants, but caller-owned selection and navigation still need deliberate behavior.