Table cells
The cells a register repeats on every row: the amount, the date, the identifier, the two-line name, what to show when there is nothing, and the menu of actions at the end.
| Customer | Amount | Reference | Created | Actions |
|---|---|---|---|---|
Mariam Keita mariam@example.com | $80.00 | INV-0041 | May 9, 2026 | |
Chidi Okeke chidi@example.com | $142.50 | Not set | May 8, 2026 | |
Guest checkout | $32.00 | INV-0038 | May 8, 2026 |
import { Avatar } from '@/components/avatar'
import { DropdownDivider, DropdownItem, DropdownLabel } from '@/components/dropdown'
import { RowMenu } from '@/components/row-menu'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'
import { AmountCell, DateCell, EmptyCell, MonoCell, StackedCell } from '@/components/table-cells'
import { ArchiveBoxIcon, ClipboardIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/16/solid'
type Payment = {
id: string
reference: string | null
customer: string | null
email: string | null
amount: string
date: string
}
function initials(name: string) {
return name
.split(' ')
.map((part) => part[0])
.join('')
}
export default function Example({ payments }: { payments: Payment[] }) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeader>Customer</TableHeader>
<TableHeader className="text-right">Amount</TableHeader>
<TableHeader>Reference</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader className="relative w-0">
<span className="sr-only">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{payments.map((payment) => (
<TableRow key={payment.id}>
<TableCell>
{payment.customer ? (
<StackedCell
media={<Avatar initials={initials(payment.customer)} className="size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />}
title={payment.customer}
subtitle={payment.email}
/>
) : (
<EmptyCell>Guest checkout</EmptyCell>
)}
</TableCell>
<TableCell className="text-right">
<AmountCell>{payment.amount}</AmountCell>
</TableCell>
<TableCell>
{payment.reference ? <MonoCell>{payment.reference}</MonoCell> : <EmptyCell>Not set</EmptyCell>}
</TableCell>
<TableCell>
<DateCell>{payment.date}</DateCell>
</TableCell>
<TableCell>
<RowMenu label={`Actions for ${payment.id}`}>
<DropdownItem href={`/payments/${payment.id}`}>
<EyeIcon />
<DropdownLabel>View details</DropdownLabel>
</DropdownItem>
<DropdownItem href={`/payments/${payment.id}/edit`}>
<PencilSquareIcon />
<DropdownLabel>Edit</DropdownLabel>
</DropdownItem>
<DropdownItem onClick={() => navigator.clipboard.writeText(payment.id)}>
<ClipboardIcon />
<DropdownLabel>Copy payment ID</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem danger>
<ArchiveBoxIcon />
<DropdownLabel>Archive</DropdownLabel>
</DropdownItem>
</RowMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}<script setup lang="ts">
import AmountCell from '@/components/amount-cell.vue'
import Avatar from '@/components/avatar.vue'
import DateCell from '@/components/date-cell.vue'
import DropdownDivider from '@/components/dropdown-divider.vue'
import DropdownItem from '@/components/dropdown-item.vue'
import DropdownLabel from '@/components/dropdown-label.vue'
import EmptyCell from '@/components/empty-cell.vue'
import MonoCell from '@/components/mono-cell.vue'
import RowMenu from '@/components/row-menu.vue'
import StackedCell from '@/components/stacked-cell.vue'
import Table from '@/components/table.vue'
import TableBody from '@/components/table-body.vue'
import TableCell from '@/components/table-cell.vue'
import TableHead from '@/components/table-head.vue'
import TableHeader from '@/components/table-header.vue'
import TableRow from '@/components/table-row.vue'
import { ArchiveBoxIcon, ClipboardIcon, EyeIcon, PencilSquareIcon } from '@heroicons/vue/16/solid'
type Payment = {
id: string
reference: string | null
customer: string | null
email: string | null
amount: string
date: string
}
defineProps<{ payments: Payment[] }>()
function initials(name: string) {
return name
.split(' ')
.map((part) => part[0])
.join('')
}
function copyId(id: string) {
navigator.clipboard.writeText(id)
}
</script>
<template>
<Table>
<TableHead>
<TableRow>
<TableHeader>Customer</TableHeader>
<TableHeader class="text-right">Amount</TableHeader>
<TableHeader>Reference</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader class="relative w-0">
<span class="sr-only">Actions</span>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
<TableRow v-for="payment in payments" :key="payment.id">
<TableCell>
<StackedCell v-if="payment.customer" :title="payment.customer" :subtitle="payment.email ?? undefined">
<template #media>
<Avatar :initials="initials(payment.customer)" class="size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />
</template>
</StackedCell>
<EmptyCell v-else>Guest checkout</EmptyCell>
</TableCell>
<TableCell class="text-right">
<AmountCell>{{ payment.amount }}</AmountCell>
</TableCell>
<TableCell>
<MonoCell v-if="payment.reference">{{ payment.reference }}</MonoCell>
<EmptyCell v-else>Not set</EmptyCell>
</TableCell>
<TableCell>
<DateCell>{{ payment.date }}</DateCell>
</TableCell>
<TableCell>
<RowMenu :label="`Actions for ${payment.id}`">
<DropdownItem :href="`/payments/${payment.id}`">
<EyeIcon />
<DropdownLabel>View details</DropdownLabel>
</DropdownItem>
<DropdownItem :href="`/payments/${payment.id}/edit`">
<PencilSquareIcon />
<DropdownLabel>Edit</DropdownLabel>
</DropdownItem>
<DropdownItem @click="copyId(payment.id)">
<ClipboardIcon />
<DropdownLabel>Copy payment ID</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem danger>
<ArchiveBoxIcon />
<DropdownLabel>Archive</DropdownLabel>
</DropdownItem>
</RowMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
import clsx from 'clsx'
import type React from 'react'
export function AmountCell({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
return (
<p
{...props}
className={clsx(
className,
'text-base/6 font-medium whitespace-nowrap text-zinc-950 tabular-nums sm:text-sm/6 dark:text-white'
)}
/>
)
}
export function DateCell({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
return (
<p
{...props}
className={clsx(className, 'text-base/6 whitespace-nowrap text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
/>
)
}
export function MonoCell({
strong = false,
className,
...props
}: { strong?: boolean } & React.ComponentPropsWithoutRef<'p'>) {
return (
<p
{...props}
className={clsx(
className,
'truncate font-mono text-base/6 sm:text-sm/6',
strong && 'text-zinc-950 dark:text-white font-medium'
)}
/>
)
}
export function EmptyCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'p'>) {
return (
<p {...props} className={clsx(className, 'text-base/6 text-zinc-400 sm:text-sm/6 dark:text-zinc-500')}>
{children ?? (
<>
<span aria-hidden="true">—</span>
<span className="sr-only">None</span>
</>
)}
</p>
)
}
export function StackedCell({
media,
title,
subtitle,
className,
...props
}: {
media?: React.ReactNode
title: React.ReactNode
subtitle?: React.ReactNode
} & React.ComponentPropsWithoutRef<'div'>) {
return (
<div {...props} className={clsx(className, 'flex min-w-0 items-center gap-3')}>
{media}
<div className="min-w-0">
<p className="truncate text-base/6 font-medium text-zinc-950 sm:text-sm/6 dark:text-white">{title}</p>
{subtitle && (
<p className="mt-0.5 truncate text-base/6 sm:text-sm/6 text-zinc-500 dark:text-zinc-400">{subtitle}</p>
)}
</div>
</div>
)
}Also copy Dropdown.
<template>
<p class="text-base/6 font-medium whitespace-nowrap text-zinc-950 tabular-nums sm:text-sm/6 dark:text-white">
<slot />
</p>
</template>Also copy Dropdown.
Component API
| Prop | Default | Description |
|---|---|---|
AmountCellrenders a <p> element | ||
| This component does not expose any component-specific props. | ||
DateCellrenders a <p> element | ||
| This component does not expose any component-specific props. | ||
MonoCellrenders a <p> element | ||
strong | false | Bold and full color, for the identifier the row is about. |
EmptyCellrenders a <p> element | ||
children | — | A dash, read as “None” by screen readers, or a word that says why there is nothing. |
StackedCellrenders a <div> element | ||
media | - | An avatar or a logo, sized by you and `shrink-0`. |
title | - | The first line, bold. |
subtitle | - | The second line, muted. Both truncate once the cell has a width, a `max-w-*` on the `TableCell`. |
RowMenubuilt on the <Dropdown> component | ||
label | 'Actions' | Accessible name of the ellipsis button. |
className | - | Classes of the menu. It is `min-w-48` and anchored bottom end. |
children | - | The `DropdownItem`s. |
| Prop | Default | Description |
|---|---|---|
AmountCellrenders a <p> element | ||
| This component does not expose any component-specific props. | ||
DateCellrenders a <p> element | ||
| This component does not expose any component-specific props. | ||
MonoCellrenders a <p> element | ||
strong | false | Bold and full color, for the identifier the row is about. |
EmptyCellrenders a <p> element | ||
#default | — | A dash, read as “None” by screen readers, or a word that says why there is nothing. |
StackedCellrenders a <div> element | ||
#media | - | An avatar or a logo, sized by you and `shrink-0`. |
title | - | The first line, bold. Use the `#title` slot for markup. |
subtitle | - | The second line, muted. Use the `#subtitle` slot for markup. Both truncate once the cell has a width, a `max-w-*` on the `TableCell`. |
RowMenubuilt on the <Dropdown> component | ||
label | 'Actions' | Accessible name of the ellipsis button. |
#default | - | The `DropdownItem`s. The menu is `min-w-48` and anchored bottom end. |
Examples
In a data table
The cells take the value already formatted. In a DataTable, return them from the column's cell:
$1,284.00
import { AmountCell, DateCell } from '@/components/table-cells'
import type { ColumnDef } from '@tanstack/react-table'
type Payment = { id: string; amount: number; createdAt: string }
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const day = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
export const columns: ColumnDef<Payment, unknown>[] = [
{
id: 'amount',
accessorFn: (row) => row.amount,
header: 'Amount',
cell: ({ row }) => <AmountCell>{money.format(row.original.amount / 100)}</AmountCell>,
meta: { label: 'Amount', cellClass: 'text-right' },
},
{
id: 'createdAt',
accessorFn: (row) => row.createdAt,
header: 'Created',
cell: ({ row }) => <DateCell>{day.format(new Date(row.original.createdAt))}</DateCell>,
meta: { label: 'Created' },
},
]<DataTable :table="table">
<template #cell-amount="{ row }">
<AmountCell>{{ money.format(row.amount / 100) }}</AmountCell>
</template>
<template #cell-createdAt="{ row }">
<DateCell>{{ day.format(new Date(row.createdAt)) }}</DateCell>
</template>
</DataTable>Stacked cell
Two lines, and a media before them when the row has one. Size the media yourself and keep it shrink-0, the text is what truncates:
Mariam Keita
mariam@example.com
Visa •••• 4242
Expires 04/28
<StackedCell
media={<Avatar initials="MK" className="size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />}
title="Mariam Keita"
subtitle="mariam@example.com"
/>
<StackedCell title="Visa •••• 4242" subtitle="Expires 04/28" /><StackedCell title="Mariam Keita" subtitle="mariam@example.com">
<template #media>
<Avatar initials="MK" class="size-8 shrink-0 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" />
</template>
</StackedCell>
<StackedCell title="Visa •••• 4242" subtitle="Expires 04/28" />Empty values
A dash when the column is optional and the row simply has none. A word when the absence means something, Guest checkout, Never selected:
None
Not set
Guest checkout
<EmptyCell />
<EmptyCell>Not set</EmptyCell>
<EmptyCell>Guest checkout</EmptyCell>Row menu
The actions of a row behind an ellipsis, in the last column. The trigger sits above the row link, so a click opens the menu rather than the row. Put the destructive item last, after a divider, with danger:
<RowMenu label="Customer actions">
<DropdownItem href="/customers/cus_1">
<EyeIcon />
<DropdownLabel>View details</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem danger>
<ArchiveBoxIcon />
<DropdownLabel>Archive customer</DropdownLabel>
</DropdownItem>
</RowMenu><RowMenu label="Customer actions">
<DropdownItem href="/customers/cus_1">
<EyeIcon />
<DropdownLabel>View details</DropdownLabel>
</DropdownItem>
<DropdownDivider />
<DropdownItem danger @click="archive">
<ArchiveBoxIcon />
<DropdownLabel>Archive customer</DropdownLabel>
</DropdownItem>
</RowMenu>