Skip to content
  1. Tables
  2. Table cells

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.

CustomerAmountReferenceCreatedActions

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

PropDefaultDescription
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
strongfalseBold 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.
PropDefaultDescription
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
strongfalseBold 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>