Skip to main content
Urbicon UI

Custom Cells

Per-column snippets and global cell overrides for rich, data-driven cell rendering.

A column's cell property takes a snippet that renders that column's cells. <Table> also accepts a child snippet named cell that covers every column at once, and hands it the column as a third argument to branch on. Whatever they render, search, sort, grouping and summaries keep working on the accessor's output, so a badge or a progress bar never changes what a column sorts by.

Status Badges & Progress Bars

Per-column snippets transform raw values into semantic badges and visual progress indicators.
Name
Role
Status
Projects
Emma Wilson
Staff Engineer
active
12
Liam Chen
Product Designer
active
8
Sofia Martinez
Engineering Manager
active
15
James Park
Frontend Developer
on-leave
6
Aisha Patel
Data Scientist
active
10
Noah Kim
DevOps Engineer
active
9
<script lang="ts">
  import { Table, type Column } from '@urbicon-ui/table';
  import { Badge } from '@urbicon-ui/blocks';

  const columns: Column<Employee>[] = [
    { accessor: 'name', title: 'Name', sortable: true },
    { accessor: 'role', title: 'Role' },
    { accessor: 'status', title: 'Status', cell: statusCell },
    { accessor: 'projects', title: 'Projects', cell: projectsCell }
  ];
</script>

{#snippet statusCell(item: Employee, value: unknown)}
  <Badge
    intent={value === 'active' ? 'success' : value === 'on-leave' ? 'warning' : 'danger'}
    size="xs"
  >{value}</Badge>
{/snippet}

{#snippet projectsCell(item: Employee, value: unknown)}
  <div class="flex items-center gap-2">
    <div class="h-1.5 w-16 rounded-full bg-surface-subtle">
      <div class="h-full rounded-full bg-primary" style="width: {(Number(value) / 20) * 100}%"></div>
    </div>
    <span class="text-xs">{value}</span>
  </div>
{/snippet}

<Table items={data} {columns} />

Rich Multi-Info Cells

Combine avatar initials, name, subtitle, and inline badges for information-dense rows.
Employee
Department
Salary
Status
EW

Emma Wilson

emma@acme.dev

Platform
142.000,00 €
active
LC

Liam Chen

liam@acme.dev

Design
98.000,00 €
active
SM

Sofia Martinez

sofia@acme.dev

Platform
165.000,00 €
active
JP

James Park

james@acme.dev

Product
92.000,00 €
on-leave
AP

Aisha Patel

aisha@acme.dev

Data
128.000,00 €
active
NK

Noah Kim

noah@acme.dev

Platform
115.000,00 €
active
const columns: Column<Employee>[] = [
  { accessor: 'name', title: 'Employee', cell: employeeCell },
  { accessor: 'department', title: 'Department', sortable: true },
  { accessor: 'salary', title: 'Salary', cell: salaryCell, dataType: 'number' },
  { accessor: 'status', title: 'Status', cell: statusCell }
];

{#snippet employeeCell(item: Employee)}
  <div class="flex items-center gap-3">
    <div class="bg-primary-subtle text-primary flex h-8 w-8 shrink-0
             items-center justify-center rounded-full text-xs font-bold">
      {item.name.split(' ').map((n) => n[0]).join('')}
    </div>
    <div class="min-w-0">
      <p class="text-sm font-medium truncate">{item.name}</p>
      <p class="text-xs text-text-tertiary truncate">{item.email}</p>
    </div>
  </div>
{/snippet}

{#snippet salaryCell(item: Employee, value: unknown)}
  <div class="text-right">
    <span class="text-sm font-semibold tabular-nums">
      {Number(value).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
    </span>
    {#if Number(value) > 130000}
      <span class="ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-success"></span>
    {/if}
  </div>
{/snippet}

Heat Map Cells

A background colour derived from the value, so a pattern across rows is visible without reading them. These are literal oklch() colours and stay as they are in dark mode; reach for design tokens when a cell should follow the theme.
Name
Role
Dept.
Projects
Emma Wilson
Staff Engineer
Platform
12
Liam Chen
Product Designer
Design
8
Sofia Martinez
Engineering Manager
Platform
15
James Park
Frontend Developer
Product
6
Aisha Patel
Data Scientist
Data
10
Noah Kim
DevOps Engineer
Platform
9
{#snippet heatCell(item: Employee, value: unknown)}
  {@const pct = Math.min(Number(value) / 20, 1)}
  {@const hue = pct * 142}
  <div
    class="mx-auto flex h-8 w-12 items-center justify-center rounded-lg
           text-xs font-bold tabular-nums"
    style="background: oklch(0.92 0.06 {hue}); color: oklch(0.35 0.12 {hue})"
  >
    {value}
  </div>
{/snippet}

<Table items={data} columns={[
  ...,
  { accessor: 'projects', title: 'Projects', cell: heatCell, align: 'center' }
]} />

Global Cell Override

One snippet renders every column. resolveColumnId returns a column's id, falling back to a string accessor, so it also names function-accessor and synthetic columns.
Name
Role
Department
Location
Emma WilsonStaff EngineerPlatform Berlin
Liam ChenProduct DesignerDesign Hamburg
Sofia MartinezEngineering ManagerPlatform Munich
James ParkFrontend DeveloperProduct Remote
<script lang="ts">
  import { resolveColumnId, Table, type Column } from '@urbicon-ui/table';
  import { Badge } from '@urbicon-ui/blocks';
</script>

<Table {items} {columns}>
  {#snippet cell(item: Employee, value: unknown, column: Column<Employee>)}
    {#if resolveColumnId(column) === 'department'}
      <Badge variant="outlined" intent="neutral" size="xs">{value}</Badge>
    {:else}
      <span class="text-sm">{value}</span>
    {/if}
  {/snippet}
</Table>

A cell that grows past a few lines is easier to keep as a component. column.component renders a Svelte component per cell. It receives the row as item, the table's size and the column's align, plus whatever componentProps(item) returns; the cell value is not among them, so read it off item.

column.formatter is the last path, (value, item) => string | null, for a text transform with no markup. When several are set on one column, the first of table cell, column.cell, column.component and column.formatter wins, in that order.