Hacktron Design System

Table

A data table with sorting, row selection, column-driven cells, and its own loading/error/empty states.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table :data="repositories" :columns="columns" empty-icon="i-lucide-search-x" empty-title="No results" />
</template>

Props

Name Type Default
dataT[]
columnsTableColumn<T>[]
density"default" | "compact""default"
clickablebooleanfalse
uiPartial<Record<'base' | 'root' | 'thead' | 'tbody' | 'tr' | 'td' | 'th', string>>
loadingboolean
loadingStyle"bar" | "skeleton""bar"
loadingRowsnumber3
errorboolean
errorTextstring
emptyIconstring
emptyTitlestring
emptyDescriptionstring
...propsUTable props (sorting, rowSelection, getRowId, empty, sortingOptions, ...)

Slots

Name Description
emptyOverrides the built-in emptyIcon/emptyTitle/emptyDescription state entirely, for a table whose empty state needs more than icon+title+description.
*Every other UTable slot (per-column *-header/*-cell/*-footer, loading, caption, body-top, body-bottom, ...) forwards through unchanged — column ids are arbitrary per table instance, so these can't be enumerated here.

Usage

Examples

Empty

Header hides automatically when data is empty. emptyIcon/emptyTitle/emptyDescription render a centered empty state in place of UTable's own plain text.

No repositories match your filters

Try adjusting or clearing your filters.

<template>
  <Table
    :data="[]"
    :columns="columns"
    empty-icon="i-lucide-search-x"
    empty-title="No repositories match your filters"
    empty-description="Try adjusting or clearing your filters."
  />
</template>

Row selection

createRowSelectColumn() (from this package's utils/table.ts) is a ready-made indeterminate select-all + per-row checkbox column.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table :data="data" :columns="[createRowSelectColumn(), ...columns]" />
</template>

Sortable header

createSortableHeader(label, info?) builds a clickable header — hover highlights both the label and the sort icon to text-primary as one unit, and the optional info tooltip explains a non-obvious column.

payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table
    :data="data"
    :columns="[
      { accessorKey: 'name', header: createSortableHeader('Name') },
      { accessorKey: 'visibility', header: createSortableHeader('Visibility') },
      { accessorKey: 'findings', header: createSortableHeader('Findings', 'Open findings on the default branch') },
    ]"
  />
</template>

Row actions

createActionsColumn(getItems, label?) adds a right-aligned kebab button opening a DropdownMenu of row actions — this design system's own DropdownMenu, not the raw Nuxt UI one.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table
    :data="data"
    :columns="[
      ...columns,
      createActionsColumn((row) => [
        { label: 'View', icon: 'i-lucide-eye' },
        { label: 'Remove', icon: 'i-lucide-trash', color: 'error', onSelect: () => remove(row.original) },
      ]),
    ]"
  />
</template>

Numeric columns

createNumericColumn(accessorKey, header, { color?, format? }) right-aligns the header and cell and applies tabular-nums — defaults to tertiary since a plain count isn't the row's headline; scanCostUsd opts into primary here since cost is this table's own headline metric. A null value always renders as a plain '-' in tertiary, overriding color.

NameFindingsScan cost
payments-service3$4.82
auth-gateway0$1.15
public-docs1-
<template>
  <Table
    :data="data"
    :columns="[
      { accessorKey: 'name', header: 'Name' },
      createNumericColumn('findings', 'Findings'),
      createNumericColumn('scanCostUsd', 'Scan cost', {
        color: 'primary',
        format: (value) => `$${value.toFixed(2)}`,
      }),
    ]"
  />
</template>

Switch column

createSwitchColumn(accessorKey, header, onToggle, { ariaLabel, disabledReason? }) — ariaLabel names each row's switch for screen readers (the switch has no visible label of its own); the disabled reason (when given) both disables the Switch and becomes its tooltip, same shape as members-table.vue's own dev-seat toggle. Centers via cellLayout's real flex wrapper, since meta.class's text-align only works on inline-level content and Switch's own root is display:flex.

NameEnabled
payments-service
auth-gateway
public-docs
<template>
  <Table
    :data="data"
    :columns="[
      { accessorKey: 'name', header: 'Name' },
      createSwitchColumn(
        'enabled',
        'Enabled',
        (value, row) => { row.original.enabled = value },
        {
          ariaLabel: (row) => 'Enable ' + row.original.name,
          disabledReason: (row) => row.original.canToggle ? null : 'Requires an SCM connection to toggle',
        },
      ),
    ]"
  />
</template>

Select column

createSelectColumn(accessorKey, header, items, onChange) — items can be a plain array or a function of the row (e.g. narrowing choices by permission). onChange receives the row so the caller can gate the change behind a confirm flow first, same as members-table.vue's own role picker does, rather than mutating directly.

NameRole
jane
alex
sam
<template>
  <Table
    :data="data"
    :columns="[
      { accessorKey: 'name', header: 'Name' },
      createSelectColumn(
        'role',
        'Role',
        [
          { value: 'admin', label: 'Admin' },
          { value: 'member', label: 'Member' },
          { value: 'viewer', label: 'Viewer' },
        ],
        (value, row) => { row.original.role = value },
      ),
    ]"
  />
</template>

Combined columns

Every column-builder helper composed in one table: row selection, an avatar+name+subtitle cell, a sortable link cell, a status badge, a numeric column, a truncated-with-tooltip cell, a Switch cell, and row actions — the way a real table actually mixes cell types, not each shown in isolation.

OwnerVisibilityFindingsDescriptionEnabled
s

security-team

payments-service

payments-servicePrivate3Handles card processing and payout reconciliation for the whole platform.
p

platform-team

auth-gateway

auth-gatewayPrivate0Central authentication gateway shared by every internal service.
d

docs-team

public-docs

public-docsPublic1Public-facing documentation site, deployed on every merge to main.
<template>
  <Table
    :data="data"
    :columns="[
      createRowSelectColumn(),
      createAvatarColumn('ownerName', 'Owner', { subtitle: (row) => row.original.name }),
      createLinkColumn('name', createSortableHeader('Repository'), (row) => row.original.repoUrl),
      createBadgeColumn('visibility', 'Visibility', {
        variant: (value) => value === 'Public' ? 'success' : 'default',
      }),
      createNumericColumn('findings', 'Findings'),
      createTruncatedColumn('description', 'Description'),
      createSwitchColumn(
        'enabled',
        'Enabled',
        (value, row) => { row.original.enabled = value },
        {
          ariaLabel: (row) => 'Enable ' + row.original.name,
          disabledReason: (row) => row.original.canToggle ? null : 'Requires an SCM connection to toggle',
        },
      ),
      createActionsColumn((row) => [
        { label: 'View', icon: 'i-lucide-eye' },
        { label: 'Remove', icon: 'i-lucide-trash', color: 'error' },
      ]),
    ]"
  />
</template>

Compact density

For a dashboard/admin table where per-row info is short.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table :data="data" :columns="columns" density="compact" />
</template>

Clickable rows

Appends cursor-pointer to the row — wire actual navigation through UTable's own onSelect.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>
  <Table :data="data" :columns="columns" clickable :onSelect="(row) => navigateTo(row.original.name)" />
</template>

Loading (cold start)

loadingStyle set to skeleton keeps the real header and column widths, replacing each body cell with a skeleton bar — for a first load with nothing to show yet, distinct from the bar default (a thin bar over stale data during a refetch).

Loading table data
<template>
  <Table :data="data" :columns="columns" :loading="loading" loading-style="skeleton" />
</template>

Error

error replaces the table with errorText — checked before loading/empty, for a request that failed rather than one that's still loading or genuinely returned nothing.

Failed to load repositories. Try again.

<template>
  <Table :data="data" :columns="columns" error error-text="Failed to load repositories. Try again." />
</template>