Customization
Empty states, style slot overrides, presets, and state persistence.
Empty, loading and error states
Custom Empty State
<tbody>, so it must be table-row markup.Name | Role | Department | Location | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
No results found Try adjusting your filters or search term. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
<script>
import { Table } from '@urbicon-ui/table';
import { SearchIcon } from '@urbicon-ui/blocks';
</script>
<Table {items} {columns}>
{#snippet emptyState()}
<tr>
<!-- colspan 99 spans whatever the column count turns out to be -->
<td colspan="99" class="py-12 text-center">
<div class="mx-auto max-w-xs">
<SearchIcon size={40} class="text-text-tertiary mx-auto mb-3" />
<p class="text-text-primary text-lg font-semibold">No results found</p>
<p class="text-text-secondary mt-1 text-sm">
Try adjusting your filters or search term.
</p>
</div>
</td>
</tr>
{/snippet}
</Table>Below cardsBelow, where each row becomes a card, the
snippet is not used: row markup cannot live in a <div>, so the card list shows the plain noDataText instead. loadingState and errorState take the same table-row markup and fall
back to loadingText and errorText. What puts the table into those two states
is its source: hand in rows and you report loading and error yourself, or give it a query and it manages both (While the rows are loading).
Style slot overrides
Style Slot Overrides
cell: 'px-6' replaces the default padding rather than fighting it. No !important needed.<Table
{items}
{columns}
slotClasses={{
container: 'my-custom-container',
row: 'hover:bg-primary-subtle',
headerRow: 'bg-surface-elevated',
cell: 'px-6',
filterBar: 'mb-6'
}}
/>Seventeen slots, one per element the table renders, are listed with what each one wraps
under TableSlotClasses: the frame around the table (container, toolbar, scrollArea), every part of the grid down to the
single cell, the rows the table adds itself (groupHeader, summaryRow), the three state containers, and mobileCard for one record of the card list.
Two are narrower than their names suggest. cell and headerCell cover the cells of your columns, not the
table's own: the selection checkbox, the expand chevron and the group spacer keep their
fixed widths, so padding you add here cannot deform them. Reach those through row.
unstyled is the other end of the same dial. It drops
every class the variants emit and leaves only what you pass in slotClasses, so plan on restyling borders, padding,
surfaces and row states yourself. Which layout shows is structure rather than style, so the
card switch survives; the classes that pin the toolbar and header do not, and Sticky Pinning says which slots to put them back on.
Presets and provider defaults
A Look Every Table Shares
BlocksProvider styles every table below it; preset names one of its looks for this table.<script>
import { BlocksProvider } from '@urbicon-ui/blocks';
import { Table } from '@urbicon-ui/table';
</script>
<BlocksProvider
defaults={{
Table: {
slotClasses: { headerCell: 'text-text-tertiary text-xs uppercase tracking-wider' },
overrides: [{ variant: 'framed', class: { scrollArea: 'shadow-none' } }]
}
}}
presets={{
Table: {
brand: { slotClasses: { headerRow: 'bg-primary-subtle', row: 'hover:bg-primary-subtle' } }
}
}}
>
<!-- every table below the provider gets the header; this one is branded too -->
<Table {items} {columns} variant="framed" preset="brand" />
</BlocksProvider>defaults.Table and a preset take the same slot names
as slotClasses, and the five layers resolve in one
order: the provider's slotClasses, its matching overrides, the preset's slotClasses, the preset's own overrides (a preset may carry them too), then the
table's own slotClasses. A later layer wins per
Tailwind bucket, so a preset's cell: 'py-1' gives way
to py-3 on the instance (Merge behavior). The table resolves the layers once, so a preset reaches the header, the rows and the
card list alike.
An overrides rule matches the table's variant axes: variant, size, cardsBelow, stickyToolbar and contained. The last two are the resolved modes rather
than the props: a table with fit="viewport" matches { contained: true }, and one whose
toolbar pins to the page matches { stickyToolbar: true }. unstyled on the provider strips the table like the prop
does (the look goes, the card switch stays) and, unlike the prop, also strips the search field
in the toolbar in the same step.
What the table remembers
State Persistence
<script>
import { Table, createTableView, bindViewToStorage } from '@urbicon-ui/table';
// The view: which rows the reader is looking at.
const view = createTableView({ defaults: { pageSize: 25 } });
bindViewToStorage(view, { key: 'team-roster' });
</script>
<!-- The preferences: how the table looks. -->
<Table {items} {columns} {view} prefs={{ storage: 'team-roster', persistSelection: true }} />The view (search, sort, page, page size, filters, grouping) decides which rows a reader
sees. It lives on a view object you own, and bindViewToStorage gives that object a memory: call it
while the component initialises, and it tears itself down with the component. The
preferences (hidden columns, column order, summaries, and opt-in selection) decide how the
table looks; they belong to the table and travel through prefs. The two channels write under different key
prefixes, so one name is safe for both.
Narrowing What Is Stored
axes narrows what is remembered. The default is every setting but the page number.<script>
const view = createTableView();
const saved = bindViewToStorage(view, {
key: 'team-roster',
// one or more of 'search' | 'sort' | 'page' | 'pageSize' | 'filters' | 'groupBy'
axes: ['sort', 'pageSize'], // remember the ordering, forget search and filters
storage: sessionStorage, // tab-scoped; localStorage is the default
debounceMs: 500
});
// saved.flush() writes the pending change now, e.g. before a programmatic
// navigation; the teardown drops what is still pending.
// saved.clear() is the "reset saved view" button: it removes the stored
// entry and leaves the live view untouched.
</script>
<Table
{items}
{columns}
{view}
prefs={{ storage: { key: 'team-roster', kind: 'sessionStorage' } }}
/>storage takes any Storage object here, so sessionStorage scopes the memory to the tab. The
preferences channel spells the same choice differently: prefs.storage is the key as a string, or { key, kind, debounceMs } when you want more than
the key. Writes are debounced on both sides, and the view binding hands back flush and clear.
Every view setting except the page number is stored
Naming a page is what a link is for, so a fresh visit starts on page one. The page size is stored: "yesterday's page size is still set" is squarely this binding's promise. To keep the page number too, bind the same view to the URL withbindViewToUrl (URL State).Only what the reader changed
A default is never written back, so a value you change indefaults later reaches
everyone who has not overridden that setting. What the URL binding applies is not the reader's
doing either, so arriving on someone else's link leaves the saved view alone.Clearing is a state
A setting the reader emptied (no sort, no filters, no grouping, no summaries, no hidden columns) restores empty and wins over the matching default. Only a missing or unreadable entry falls back.Selection is off by default
persistSelection keys rows by item.id; without stable ids the
selection falls back to the row position and restores onto different rows after a reorder.Upgrading from v7
The preferences keep their storage keys, so a reader's column layout survives. The view settings moved into one entry per view, and the per-setting keys v7 wrote (table_sort_*, table_search_*, table_filters_*, table_group_by_*) are no longer read. Page size is new to this channel: v7
persisted no pagination at all.