Skip to main content
Urbicon UI

Customization

Empty states, style slot overrides, presets, and state persistence.

Empty, loading and error states

Custom Empty State

The snippet renders into the desktop <tbody>, so it must be table-row markup.
Name
Role
Department
Location

No results found

Try adjusting your filters or search term.

No data available
<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

Your classes run through the same conflict fold as the built-in ones, so 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

A 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

Two kinds of state, two channels: the view is yours, the preferences are the table's.
<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 with bindViewToUrl (URL State).

Only what the reader changed

A default is never written back, so a value you change in defaults 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.