Hacktron Design System

Table

A data table with sorting, row selection, and loading, error and empty states.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>  <Table :data="repositories" :columns="columns" empty-title="No results" empty-description="Try adjusting or clearing your filters." /></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"
loadingRowsnumber15
errorboolean-
errorTextstring-
retryLabelstring-
minWidthnumber768
emptyTitlestring-
emptyDescriptionstring-
...propsUTable props (sorting, rowSelection, getRowId, empty, sortingOptions, ...)-

Slots

Name Description
emptyOverrides the built-in TableEmptyRow rendering entirely, for a table whose empty state needs more than 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.

Guidelines

Structure

Unlike shadcn's Table/TableHeader/TableBody/TableRow/TableCell family, this is one component, not a set of subcomponents you nest - every semantic element (<table>, <thead>, <tbody>, <tr>, <th>, <td>) is rendered internally from columns/data. You build a table by assembling columns as a plain array, mixing this package's create*Column helpers (utils/table.ts) with plain TableColumn defs for anything with no helper yet:

const columns = [
  createRowSelectColumn<Repo>(),
  createAvatarColumn<Repo>('ownerName', 'Owner', { subtitle: row => row.original.name }),
  createLinkColumn<Repo>('name', createSortableHeader('Repository'), row => row.original.repoUrl),
  createBadgeColumn<Repo>('visibility', 'Visibility', {
    variant: value => value === 'Public' ? 'success' : 'default',
  }),
  createNumericColumn<Repo>('findings', 'Findings'),
  createTruncatedColumn<Repo>('description', 'Description'),
  createSwitchColumn<Repo>('enabled', 'Enabled', (value, row) => { row.original.enabled = value }, { ariaLabel: row => `Enable ${row.original.name}` }),
  createActionsColumn<Repo>(row => [
    { label: 'View', icon: 'i-lucide-eye' },
    { label: 'Remove', icon: 'i-lucide-trash', color: 'error' },
  ]),
]
<Table :data="repos" :columns="columns" />

Order in the array is column order left-to-right - createRowSelectColumn/createAvatarColumn first (selection and identity lead a row), createActionsColumn last (row actions trail it), matching every real table in apps/frontend. Each helper only needs an accessorKey plus a header (string or createSortableHeader(...)); the ones with no data behind them (createRowSelectColumn, createActionsColumn) take no accessorKey at all. See the "Combined columns" example below for this exact table rendered.

Every rendered element is addressed by a ui slot key (also present on the element itself as data-slot) - base (<table>), thead, tbody, tr, th, td, root (the outer wrapper, overflow-x-auto - see Layout below), and separator. This component's own ui prop exposes those eight keys (TableUiSlot); UTable itself also has caption, tfoot, empty, and loading slot keys, not restylable through this component's ui - reach them through the #caption/#empty/#loading slots or a column's own meta.class instead.

  • Caption: pass a plain caption string (falls through to UTable directly - this component doesn't declare its own caption prop) or a #caption slot for richer content. UTable renders it visually hidden (sr-only) by default - it's for screen readers identifying the table's content, not a visible title; use a heading above the table for a visible one.
  • Footer: give any column in columns a footer property, same as a plain TanStack column def - UTable renders the <tfoot> automatically whenever at least one column defines one. There's no separate footer prop on this component.
  • Separator: UTable renders its own <tr data-slot="separator"> between thead and tbody (and again before a <tfoot>), styled by Nuxt UI's own base theme - a second divider stacked on top of thead's own border-b, in a different color token. separator is hidden by default here so thead's border-b is the only line between header and rows; pass your own ui.separator to bring it back for a footer divider.

Layout

  • The table is table-fixed w-full - every column shares its width equally from its own <th>, not the natural content-width sizing a plain <table> would give it, and a TanStack column def's size is never read as an inline width here (only pinning offsets use it). Getting a column's width right is one of three explicit choices, not the browser's own auto-layout guess: fill (the default - pass no width option, share whatever's left equally with every other unwidthed column), fixColumnWidth(width, extraClass?) (a static, author-declared width for a small structural/control column that must never grow or shrink - createRowSelectColumn/createActionsColumn both use this to hug a checkbox/kebab button), and hugColumnWidth(accessorKey, headerLabel, options?) (sizes the column to the longest value actually present in the table's current data, in ch units, via meta.style - recomputes whenever data changes, unlike fixColumnWidth's fixed value). createNumericColumn separately uses plain meta.class text-right to right-align without touching width at all.
  • Cell text never wraps - UTable's own base theme sets whitespace-nowrap on every td unconditionally, and neither density's own td override touches whitespace-*, so it survives untouched (same single-line-by-default convention as Carbon/Ant Design/Material). That means a long, un-truncated value doesn't grow the row; both th and td also carry overflow-hidden, so it never bleeds into the next column.
  • overflow-hidden alone clips at the outer edge of a padded box's padding (flush with the border) - a long value would still visually eat through the full px-4/px-3.5 padding before finally getting clipped, leaving no clean space. NumericCell, Badge, LinkCell, LabeledAvatar's name/subtitle, and TruncatedCell all wrap their actual text in their own zero-padding truncate/min-w-0 inner span instead, so the clip happens at that inner span's own edge - which lines up with the cell's content-box edge, i.e. right where the padding begins, not where it ends. A hand-rolled plain-string cell has no such wrapper and will still eat through its padding on a long value. Use createTruncatedColumn/TruncatedCell for anything that might run long, so it also degrades as an ellipsis-plus-tooltip instead of a bare clip.
  • Every <tr> is relative, which is what createRowLinkColumn's full-row overlay hangs off: its link's after:absolute after:inset-0 pseudo-element stretches over the whole row. Anything interactive in another cell of that row must carry relative z-10 to sit above the overlay - createRowSelectColumn's checkbox, createActionsColumn's trigger, createSwitchColumn's switch, createSelectColumn's select, and LinkCell's anchor all do. Display-only cells (TruncatedCell, NumericCell, DateCell, Badge, LabeledAvatar) stay below it, so clicking them still navigates the row.
  • The table has a minWidth floor (768px by default) and its outer wrapper (root) is overflow-x-auto - a table with more columns than fit the viewport (or a compact table packed with several narrow columns) scrolls horizontally within itself once its container goes below that width, rather than squeezing every column arbitrarily thin or overflowing the page. Fixed-width columns don't shrink, so on a table whose other columns are fixed the fill (name) column absorbs every lost pixel and can reach zero; pass :min-width="tableMinWidth(fixedWidthPx, fillColumnCount)" (the summed fixed widths, ROW_SELECT_COLUMN_WIDTH_PX/ACTIONS_COLUMN_WIDTH_PX for the control columns, plus the number of unsized columns) so the first fill column keeps 240px and each other one 120px.

Do

  • Use density="compact" for a dashboard/admin table where per-row info is short (cost breakdowns, queue jobs); leave it at the default for everything else.
  • Use clickable when a row navigates somewhere on click, so the cursor reflects it.
  • Pass a real TableColumn<T>[] - column type safety comes from the generic T, don't widen it away with any.
  • Pick a column's width deliberately: leave it unwidthed (fill) for a data column that should share leftover space, fixColumnWidth(width, extraClass?) for a small control column that must stay an exact size (a checkbox, a kebab button), and hugColumnWidth(accessorKey, headerLabel, options?) for a data column that should size itself to its actual longest value (a date, a short status) instead of stretching or being guessed at with a static width.
  • Build column headers with createSortableHeader(label, info?)/createHeader(label, info?), a checkbox column with createRowSelectColumn<T>(), and a "more options" column with createActionsColumn<T>(getItems, label?) (a ghost IconButton kebab; a row whose getItems returns nothing gets no kebab) (label can be a row => string function so each row's kebab is named after its row) (all exported from this package's own utils/table.ts) instead of hand-rolling any of them - every real table in apps/frontend does at least one. createSortableHeader's whole header cell is the click target (industry standard - Carbon, Ant Design), not just the sort icon. Pass info to either for a column that needs explaining; it renders an InfoTooltip next to the label (a sibling of the sort button, never inside it). A third infoLabel argument names that tooltip's trigger (its default is a plain-English "More info").
  • Never hand-build page controls or fold pagination into Table itself - pair with Pagination instead; see the Tables UI pattern page for the full FilterBar → Table → footer composition this component is meant to sit inside. This component has no built-in virtualization - it assumes a bounded, paginated data array (dozens of rows, not thousands); pair with Pagination for anything larger rather than passing an unbounded array. Render the count + Pagination row with TableFooter, which also carries the loading skeleton.
  • Always pass getRowId (e.g. getRowId: row => row.id) whenever createRowSelectColumn is combined with pagination. Without it, TanStack falls back to each row's array index as its id - row 0 on page 1 and row 0 on page 2 then share the same id, so a selection made on one page silently also selects the next page's row in that same position once you paginate. RowSelectActionBar's selection state is only as correct as the ids feeding it.
  • Pass createRowSelectColumn({ disabledReason }) when some rows can't be selected and the user should know why - the row's checkbox disables, its reason shows in a tooltip, and the header's select-all skips it (so there's no need to repeat the rule in rowSelectionOptions.enableRowSelection). The header disables itself when no row on the page is selectable. A row TanStack itself marks unselectable (enableRowSelection making row.getCanSelect() false) also disables, with no tooltip. Pass selectAllLabel to name the header checkbox for the table's content (e.g. "Select all repositories") and getRowLabel to name each row's ("Select {label}"). An i18n-aware caller passes getRowAriaLabel instead, which returns each row checkbox's full translated name.
  • Use emptyTitle/emptyDescription for a table's zero-results state - it renders TableEmptyRow (a plain two-line text stack, no icon) for you, so don't also nest one inside a #empty slot yourself; don't reach for the page-level EmptyState here either, that's for a page/section with no data, not a row inside an existing table. Use loading + loadingStyle="skeleton" for a table embedded in a Card that needs a cold-start placeholder instead of UTable's own thin loading bar. The real header renders immediately (correct column labels from the first paint) with each body cell replaced by a skeleton bar - not a generic block that replaces the whole table. The row-select and row-actions columns stay blank instead, since a bar the size of a checkbox or kebab reads as a broken control.
  • Use error + errorText for a request that failed, not for a request that's still loading or one that genuinely returned nothing - while data is empty, error is checked before loading/empty and takes over the whole table. With rows already loaded (a failed background refetch, which TanStack reports with the last good data still set), the same message renders as a strip above the rows instead, so they stay visible: pass the query's raw isError and let the table decide. Add retryLabel to render a secondary Button after the text; it emits retry, the caller does the refetch (@retry="refetch()"), and it shows its spinner while loading is set, so pass isFetching as loading to give the click feedback.
  • Color cell text by role, matching the convention already live across apps/frontend's tables: text-text-primary for the row's one headline/identifying value (a name, a title), text-text-secondary for real present metadata that supports it (a role, a date, a secondary name field), text-text-tertiary for de-emphasized supporting text (counts, relative timestamps) and every column header label regardless of that column's own importance.
  • Build a numeric/currency column with createNumericColumn(accessorKey, header, { color?, format? }) (from utils/table.ts; color can also be a (value, row) => color function, e.g. to highlight non-zero counts) instead of hand-rolling one - it right-aligns the header and cell and applies tabular-nums, the combination developer-usage-table.vue's own cost/reviews columns already get right but several other tables don't (see the Don't below).
  • Render a missing/empty cell value as a plain - in text-text-tertiary, swapping the class conditionally on the value's presence (value ? 'text-text-primary' : 'text-text-tertiary') rather than hardcoding one color for both states - this is a strict, uniform rule across the real tables, not a per-table style choice.
  • For row actions: an overflow createActionsColumn for 3+ actions or any destructive one; inline hover-revealed icon buttons (members-table.vue's own pattern) for 1-2 lightweight, frequent actions. Both are correct - pick by action count, not by habit.
  • Use createSwitchColumn(accessorKey, header, onToggle, { ariaLabel, disabledReason? }) for a boolean toggle in a column (ariaLabel is required: a bare switch has no visible label, so each row's toggle needs its own screen-reader name) and createSelectColumn(accessorKey, header, items, onChange) for an inline value picker, instead of hand-rolling either - members-table.vue's own dev-seat Switch and role Select are the real precedents, both currently built on the raw Nuxt UI primitives rather than this design system's own Switch/Select. Both helpers hand the row to their callback rather than mutating anything themselves, so a caller can gate the change behind a confirm flow first (as members-table.vue's own role column already does) instead of applying it directly.
  • Use cellLayout(content, { align?, disabledTooltip? }) when hand-building a cell around a control that isn't already covered by a create*Column helper - its align option is required for anything whose root is display: flex (this package's own Switch, for one), since meta.class's text-align only affects inline-level content and silently does nothing otherwise.
  • Use createTruncatedColumn(accessorKey, header, { color?, format? }) for a value too long for its column - it's the most-repeated raw cell pattern found across apps/frontend's tables (members-table.vue, developer-usage-table.vue, repositories/table.vue), through TruncatedCell rather than a hand-built Tooltip+truncate span per table.
  • Use createBadgeColumn(accessorKey, header, { variant?, label? }) for a status/tag cell and createAvatarColumn(accessorKey, header, { subtitle?, src?, size? }) for a user/avatar cell - both route through this design system's own Badge/LabeledAvatar instead of a hand-rolled color mapping or avatar+flex-column per table. createAvatarColumn defaults to size: 'default', not LabeledAvatar's own default - its size="sm" variant silently drops the subtitle line entirely, so passing subtitle with size: 'sm' loses it.
  • Use createRowLinkColumn(accessorKey, header, to, { subtitle?, color? }) to make a whole row navigate to an in-app route - one real NuxtLink per row (middle-click and open-in-new-tab work), its label primary by default, with an optional tertiary subtitle line under it (both truncate). Prefer it over clickable + onSelect when the row's destination is a route. Return null from to for a row with nowhere to go: the label and subtitle render as plain text, with no link or overlay.
  • Use createLinkColumn(accessorKey, header, hrefFn) for an external link cell (repo/PR links) - an always-underlined text-text-tertiary link that brightens to text-text-primary on hover, opening in a new tab with noopener noreferrer, through LinkCell rather than a hand-built anchor per table. In a custom cell slot, use <LinkCell :href> for an external link (new tab, trailing arrow, screen-reader "(opens in new tab)") and <LinkCell :to> for an in-app route (same look, same tab, no arrow) that isn't the row's own destination.
  • Use createDateColumn(accessorKey, header, { mode?, color? }) for a date cell instead of hand-rolling one - members-table.vue's own createdAt/expiresAt cells (a plain formatted date, conditionally colored red when expired) are the real precedent, through DateCell. mode picks which of apps/frontend's own standardized date rules to render: 'date' (the default - a plain calendar date, no time, never relative), 'date-at-time' (always absolute, with time), 'relative-age' (always relative, ages through minutes/hours/days/months/years), 'relative-or-date' (relative under 30 days, then the date), or 'relative-or-date-at-time' (relative under an hour, then date+time). color mirrors createBadgeColumn's own variant callback, defaulting to 'secondary' - a date is supporting metadata, not a de-emphasized count. Pass format(date, row) to supply your own formatter (a translated or app-specific one); mode is ignored when it's given. Pass emptyLabel (e.g. "Never", "Not run yet") to replace the - shown for a missing date; it stays tertiary.
  • Every create*Column helper's header accepts a plain string or a pre-built header (e.g. createSortableHeader(...)) - pass createSortableHeader('Repository') as the header to createLinkColumn/createBadgeColumn/etc. when a non-standard cell type also needs to be sortable, rather than picking one or the other.

Don't

  • Don't reach for ui to change row height or padding as a matter of routine - only for a genuine one-off (e.g. a taller 2-line row). It replaces per slot: each slot string you pass replaces that slot's density default outright (so include every class the slot still needs), while slots you leave out keep their default. A custom ui.tr also drops clickable's cursor-pointer.
  • Don't hand-roll the data.length === 0 header-hiding branch - this component already does it.
  • Don't use loadingStyle="skeleton" for a background refetch/page change - that would hide data the user was already looking at. Leave loadingStyle at its bar default there.
  • Don't expect a per-column custom #id-cell slot to render during loadingStyle="skeleton" - every cell slot is suppressed in favor of the skeleton bar while loading, since the fake placeholder rows don't have real data for a custom cell to render.
  • Don't use raw Nuxt UI color aliases (text-muted, text-highlighted) for cell text - use this design system's own text-text-primary/secondary/tertiary tokens. automations/run-table.vue is the one real table that still does this; it's drift, not a pattern to copy.
  • Don't put a plain numeric count in text-text-primary just because a similar-looking metric elsewhere in the app does - apps/frontend has real, unresolved inconsistency here (cost tables tier counts as tertiary; developer-usage-table.vue tiers similar metrics as primary). Default to tertiary for a count/metric unless it's genuinely the row's headline value.
  • Don't hand-roll a plain string cell for a value that could run long - with no inner truncate/min-w-0 wrapper of its own, it eats through the td's own padding before overflow-hidden clips it flush at the border, mid-character and with no ellipsis. Use createTruncatedColumn/TruncatedCell instead, the same way every other cell type here goes through a dedicated builder rather than a bare cell function.
  • Don't put an interactive control in a createRowLinkColumn row without relative z-10 on it - the row's link overlay sits on top and swallows the click, navigating instead. Expect TruncatedCell's overflow tooltip not to open in such a row either: the overlay, not the cell, receives the hover.

Examples

Variants

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>

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>

States

Empty

Header hides automatically when data is empty. emptyTitle/emptyDescription render TableEmptyRow (a plain two-line text stack, no icon) 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-title="No repositories match your filters"    empty-description="Try adjusting or clearing your filters."  /></template>

Loading (cold start)

loadingStyle set to skeleton keeps the real header and column widths, replacing each data cell with a one-line skeleton bar (row-select and row-actions columns stay blank) - 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

With no rows to show, 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. With rows already loaded, the same message renders as a strip above them instead.

Failed to load repositories. Try again.

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

Error with retry

retryLabel adds a secondary Button after errorText that emits retry - the caller owns the refetch. Here retry shows the loading bar for a moment, then the error again.

Failed to load repositories.

NameVisibilityFindings
payments-servicePrivate3
auth-gatewayPrivate0
public-docsPublic1
<template>  <Table    :data="data"    :columns="columns"    :error="isError"    :loading="isFetching"    error-text="Failed to load repositories."    retry-label="Retry"    @retry="refetch()"  /></template>

Composition

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>

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. 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, 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-service(opens in new tab)Private3Handles card processing and payout reconciliation for the whole platform.
p

platform-team

auth-gateway

auth-gateway(opens in new tab)Private0Central authentication gateway shared by every internal service.
d

docs-team

public-docs

public-docs(opens in new tab)Public1Public-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>

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>

createRowLinkColumn(accessorKey, header, to, { subtitle?, color? }) renders a NuxtLink whose after:absolute after:inset-0 overlay makes the whole row navigable, with an optional tertiary subtitle line. The checkbox and kebab trigger carry relative z-10 so they still work on top of the overlay; clicking the findings count navigates the row.

<template>  <Table    :data="data"    :columns="[      createRowSelectColumn({ getRowLabel: (row) => row.name }),      createRowLinkColumn('name', 'Repository', (row) => `/repositories/${row.original.id}`, {        subtitle: (row) => row.original.visibility,      }),      createNumericColumn('findings', 'Findings'),      createActionsColumn((row) => [        { label: 'View', icon: 'i-lucide-eye' },        { label: 'Remove', icon: 'i-lucide-trash', color: 'error' },      ]),    ]"  /></template>