Skip to content
  1. Tables
  2. Table

Table

Rows and columns for records people scan and compare.

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'

export default function Example({ invoices }) {
  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableHeader>Invoice</TableHeader>
          <TableHeader>Customer</TableHeader>
          <TableHeader>Issued</TableHeader>
          <TableHeader className="text-right">Amount</TableHeader>
        </TableRow>
      </TableHead>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableCell className="font-medium">{invoice.id}</TableCell>
            <TableCell>{invoice.company}</TableCell>
            <TableCell className="text-zinc-500">{invoice.date}</TableCell>
            <TableCell className="text-right tabular-nums">{invoice.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
<script setup lang="ts">
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'

defineProps<{ invoices: { id: string; company: string; date: string; amount: string }[] }>()
</script>

<template>
  <Table>
    <TableHead>
      <TableRow>
        <TableHeader>Invoice</TableHeader>
        <TableHeader>Customer</TableHeader>
        <TableHeader>Issued</TableHeader>
        <TableHeader class="text-right">Amount</TableHeader>
      </TableRow>
    </TableHead>
    <TableBody>
      <TableRow v-for="invoice in invoices" :key="invoice.id">
        <TableCell class="font-medium">{{ invoice.id }}</TableCell>
        <TableCell>{{ invoice.company }}</TableCell>
        <TableCell class="text-zinc-500">{{ invoice.date }}</TableCell>
        <TableCell class="text-right tabular-nums">{{ invoice.amount }}</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.

'use client'

import clsx from 'clsx'
import type React from 'react'
import { createContext, useContext, useEffect, useState } from 'react'
import { Link } from './link'

type TableOptions = { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean }

type RowLink = { href?: string; target?: string; title?: string }

const TableContext = createContext<Required<TableOptions>>({ bleed: false, dense: false, grid: false, striped: false })

const TableRowContext = createContext<RowLink>({})

const gridBorder = 'first:border-l-0 border-l dark:border-l-white/5 border-l-zinc-950/5'

function cellPadding({ bleed, grid }: Required<TableOptions>) {
  return [
    'px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
    !bleed && 'sm:first:pl-1 sm:last:pr-1',
    grid && gridBorder,
  ]
}

const scrollArea = 'overflow-x-auto whitespace-nowrap -mx-(--gutter)'

const focusRing = 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-500'

const tableText = 'min-w-full text-sm/6 text-left dark:text-white text-zinc-950'

export function Table({
  bleed = false,
  dense = false,
  grid = false,
  striped = false,
  className,
  children,
  'aria-label': label,
  'aria-labelledby': labelledBy,
  'aria-describedby': describedBy,
  ...props
}: TableOptions & React.ComponentPropsWithoutRef<'div'>) {
  let [scroller, setScroller] = useState<HTMLDivElement | null>(null)
  let [overflowing, setOverflowing] = useState(false)

  useEffect(() => {
    if (!scroller) return
    let observer = new ResizeObserver(() => setOverflowing(scroller.scrollWidth > scroller.clientWidth))
    observer.observe(scroller)
    observer.observe(scroller.firstElementChild!)
    return () => observer.disconnect()
  }, [scroller])

  return (
    <TableContext.Provider value={{ bleed, dense, grid, striped }}>
      <div className="flow-root">
        <div
          ref={setScroller}
          {...(overflowing && {
            tabIndex: 0,
            role: label || labelledBy ? 'region' : undefined,
            'aria-label': label,
            'aria-labelledby': labelledBy,
          })}
          {...props}
          className={clsx(className, scrollArea, focusRing)}
        >
          <div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
            <table aria-label={label} aria-labelledby={labelledBy} aria-describedby={describedBy} className={tableText}>
              {children}
            </table>
          </div>
        </div>
      </div>
    </TableContext.Provider>
  )
}

export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
  return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
}

export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
  return <tbody {...props} />
}

const linkedRow =
  'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-accent-500 dark:focus-within:bg-white/2.5'

const stripedRow = 'dark:even:bg-white/2.5 even:bg-zinc-950/2.5'

const rowHover = {
  plain: 'dark:hover:bg-white/2.5 hover:bg-zinc-950/2.5',
  striped: 'dark:hover:bg-white/5 hover:bg-zinc-950/5',
}

export function TableRow({ href, target, title, className, ...props }: RowLink & React.ComponentPropsWithoutRef<'tr'>) {
  let { striped } = useContext(TableContext)

  return (
    <TableRowContext.Provider value={{ href, target, title }}>
      <tr
        {...props}
        className={clsx(
          className,
          striped && stripedRow,
          href && [linkedRow, striped ? rowHover.striped : rowHover.plain]
        )}
      />
    </TableRowContext.Provider>
  )
}

export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
  let options = useContext(TableContext)

  return (
    <th
      {...props}
      className={clsx(className, 'border-b border-b-zinc-950/10 py-2 font-medium dark:border-b-white/10', cellPadding(options))}
    />
  )
}

const rowDivider = 'dark:border-white/5 border-b border-zinc-950/5'

export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
  let options = useContext(TableContext)
  let { href, target, title } = useContext(TableRowContext)
  let [cell, setCell] = useState<HTMLElement | null>(null)
  let isFirstCell = cell?.previousElementSibling === null

  return (
    <td
      ref={href ? setCell : undefined}
      {...props}
      className={clsx(
        className,
        'relative',
        options.dense ? 'py-2.5' : 'py-4',
        !options.striped && rowDivider,
        cellPadding(options)
      )}
    >
      {href && (
        <Link
          data-row-link
          href={href}
          target={target}
          aria-label={title}
          tabIndex={isFirstCell ? 0 : -1}
          className="absolute inset-0 focus:outline-hidden"
        />
      )}
      {children}
    </td>
  )
}
<script lang="ts">
import type { ComputedRef, InjectionKey } from 'vue'

export interface TableConfig {
  bleed: boolean
  dense: boolean
  grid: boolean
  striped: boolean
}

export interface TableRowConfig {
  href?: string
  target?: string
  title?: string
}

export const TableConfigKey: InjectionKey<ComputedRef<TableConfig>> = Symbol('TrelundTable')
export const TableRowKey: InjectionKey<ComputedRef<TableRowConfig>> = Symbol('TrelundTableRow')
</script>

<script setup lang="ts">
import { computed, onBeforeUnmount, provide, ref, watch } from 'vue'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    bleed?: boolean
    dense?: boolean
    grid?: boolean
    striped?: boolean
    ariaLabel?: string
    ariaLabelledby?: string
    ariaDescribedby?: string
  }>(),
  { bleed: false, dense: false, grid: false, striped: false }
)

const scrollArea = 'overflow-x-auto whitespace-nowrap -mx-(--gutter)'

const focusRing = 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-500'

const tableText = 'min-w-full text-sm/6 text-left dark:text-white text-zinc-950'

const scroller = ref<HTMLDivElement | null>(null)
const overflowing = ref(false)

const scrollRegion = computed(() =>
  overflowing.value
    ? {
        tabindex: 0,
        role: props.ariaLabel || props.ariaLabelledby ? 'region' : undefined,
        'aria-label': props.ariaLabel,
        'aria-labelledby': props.ariaLabelledby,
      }
    : {}
)

let observer: ResizeObserver | undefined

watch(scroller, (element) => {
  observer?.disconnect()
  if (!element) return
  observer = new ResizeObserver(() => {
    overflowing.value = element.scrollWidth > element.clientWidth
  })
  observer.observe(element)
  observer.observe(element.firstElementChild!)
})

onBeforeUnmount(() => observer?.disconnect())

provide(
  TableConfigKey,
  computed(() => ({ bleed: props.bleed, dense: props.dense, grid: props.grid, striped: props.striped }))
)
</script>

<template>
  <div class="flow-root">
    <div ref="scroller" v-bind="{ ...scrollRegion, ...$attrs }" :class="[scrollArea, focusRing]">
      <div :class="['inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)']">
        <table
          :aria-label="ariaLabel"
          :aria-labelledby="ariaLabelledby"
          :aria-describedby="ariaDescribedby"
          :class="tableText"
        >
          <slot />
        </table>
      </div>
    </div>
  </div>
</template>

Component API

PropDefaultDescription
Tablerenders a <div> element
aria-label-Accessible name of the `<table>`, also given to the scroll region when the table overflows. `aria-labelledby` works the same way.
bleedfalseLet the table run to the edge of its --gutter container.
densefalseCondensed vertical spacing.
gridfalseShow vertical grid lines.
stripedfalseAlternate row backgrounds.
TableHeadrenders a <thead> element
This component does not expose any component-specific props.
TableBodyrenders a <tbody> element
This component does not expose any component-specific props.
TableRowrenders a <tr> element
href-Make the whole row a link.
target-Target of the row link.
title-Accessible name of the row link.
TableHeaderrenders a <th> element
This component does not expose any component-specific props.
TableCellrenders a <td> element
This component does not expose any component-specific props.

Usage

The parts map one to one onto the HTML table elements, so the markup stays semantic and screen readers announce headers with each cell. Align numbers to the right and give them tabular-nums so digits line up.

Invoice list

A header row in TableHead, one TableRow per record in TableBody:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/table'

export default function Example({ invoices }) {
  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableHeader>Invoice</TableHeader>
          <TableHeader>Customer</TableHeader>
          <TableHeader>Issued</TableHeader>
          <TableHeader className="text-right">Amount</TableHeader>
        </TableRow>
      </TableHead>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableCell className="font-medium">{invoice.id}</TableCell>
            <TableCell>{invoice.company}</TableCell>
            <TableCell className="text-zinc-500">{invoice.date}</TableCell>
            <TableCell className="text-right tabular-nums">{invoice.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
<script setup lang="ts">
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'

defineProps<{ invoices: { id: string; company: string; date: string; amount: string }[] }>()
</script>

<template>
  <Table>
    <TableHead>
      <TableRow>
        <TableHeader>Invoice</TableHeader>
        <TableHeader>Customer</TableHeader>
        <TableHeader>Issued</TableHeader>
        <TableHeader class="text-right">Amount</TableHeader>
      </TableRow>
    </TableHead>
    <TableBody>
      <TableRow v-for="invoice in invoices" :key="invoice.id">
        <TableCell class="font-medium">{{ invoice.id }}</TableCell>
        <TableCell>{{ invoice.company }}</TableCell>
        <TableCell class="text-zinc-500">{{ invoice.date }}</TableCell>
        <TableCell class="text-right tabular-nums">{{ invoice.amount }}</TableCell>
      </TableRow>
    </TableBody>
  </Table>
</template>

Layout

Horizontal scrolling

A table wider than its container scrolls sideways instead of squeezing its columns. Set --gutter to the horizontal padding of the container so the first and last columns line up with the content around it:

InvoiceCustomerContactEmailIssuedDueAmount
INV-2041Lumen HealthArthur Monneyarthur@lumenhealth.ioSep 18, 2026Oct 2, 2026$4,200.00
INV-2040Atelier NordLeonel Njoyaleonel@ateliernord.frSep 15, 2026Sep 29, 2026$1,180.50
INV-2039Kumo LabsZakaria Labibzakaria@kumolabs.jpSep 9, 2026Sep 23, 2026$860.00
INV-2038Baobab FreightDamilola Olowookeredamilola@baobabfreight.snSep 4, 2026Sep 18, 2026$12,940.00
INV-2037Solana StudioFabrice Yopafabrice@solana.studioAug 29, 2026Sep 12, 2026$2,315.75
<Table className="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <TableHead>
    <TableRow>
      <TableHeader>Invoice</TableHeader>
      <TableHeader>Customer</TableHeader>
      <TableHeader>Contact</TableHeader>
      <TableHeader>Email</TableHeader>
      <TableHeader>Issued</TableHeader>
      <TableHeader>Due</TableHeader>
      <TableHeader className="text-right">Amount</TableHeader>
    </TableRow>
  </TableHead>
  <TableBody>{/* ... */}</TableBody>
</Table>
<Table class="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <TableHead>
    <TableRow>
      <TableHeader>Invoice</TableHeader>
      <TableHeader>Customer</TableHeader>
      <TableHeader>Contact</TableHeader>
      <TableHeader>Email</TableHeader>
      <TableHeader>Issued</TableHeader>
      <TableHeader>Due</TableHeader>
      <TableHeader class="text-right">Amount</TableHeader>
    </TableRow>
  </TableHead>
  <TableBody><!-- ... --></TableBody>
</Table>

Edge to edge

bleed stretches the row dividers across the --gutter so they touch the edges of a card or a page section:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
<Table bleed className="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  {/* ... */}
</Table>
<Table bleed class="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <!-- ... -->
</Table>

Appearance

Dense rows

dense tightens the vertical padding for metrics and logs where people compare many rows at once:

EndpointRequestsp50p95ErrorsError rate
GET /v1/invoices48,21042 ms118 ms120.02%
POST /v1/invoices6,90496 ms240 ms310.45%
GET /v1/customers22,58738 ms104 ms40.02%
PATCH /v1/customers/:id3,11871 ms190 ms90.29%
POST /v1/payments9,442154 ms412 ms580.61%
GET /v1/events71,03629 ms87 ms60.01%
<Table dense className="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <TableHead>
    <TableRow>
      <TableHeader>Endpoint</TableHeader>
      <TableHeader className="text-right">Requests</TableHeader>
      <TableHeader className="text-right">p50</TableHeader>
      <TableHeader className="text-right">p95</TableHeader>
      <TableHeader className="text-right">Errors</TableHeader>
      <TableHeader className="text-right">Error rate</TableHeader>
    </TableRow>
  </TableHead>
  <TableBody>
    {endpoints.map((endpoint) => (
      <TableRow key={endpoint.route}>
        <TableCell className="font-mono text-xs">{endpoint.route}</TableCell>
        <TableCell className="text-right tabular-nums">{endpoint.requests}</TableCell>
        <TableCell className="text-right tabular-nums">{endpoint.p50}</TableCell>
        <TableCell className="text-right tabular-nums">{endpoint.p95}</TableCell>
        <TableCell className="text-right tabular-nums">{endpoint.errors}</TableCell>
        <TableCell className="text-right tabular-nums">{endpoint.rate}</TableCell>
      </TableRow>
    ))}
  </TableBody>
</Table>
<Table dense class="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <TableHead>
    <TableRow>
      <TableHeader>Endpoint</TableHeader>
      <TableHeader class="text-right">Requests</TableHeader>
      <TableHeader class="text-right">p50</TableHeader>
      <TableHeader class="text-right">p95</TableHeader>
      <TableHeader class="text-right">Errors</TableHeader>
      <TableHeader class="text-right">Error rate</TableHeader>
    </TableRow>
  </TableHead>
  <TableBody>
    <TableRow v-for="endpoint in endpoints" :key="endpoint.route">
      <TableCell class="font-mono text-xs">{{ endpoint.route }}</TableCell>
      <TableCell class="text-right tabular-nums">{{ endpoint.requests }}</TableCell>
      <TableCell class="text-right tabular-nums">{{ endpoint.p50 }}</TableCell>
      <TableCell class="text-right tabular-nums">{{ endpoint.p95 }}</TableCell>
      <TableCell class="text-right tabular-nums">{{ endpoint.errors }}</TableCell>
      <TableCell class="text-right tabular-nums">{{ endpoint.rate }}</TableCell>
    </TableRow>
  </TableBody>
</Table>

Column dividers

grid adds a vertical line between columns. It pairs well with dense when the table reads like a spreadsheet:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
<Table grid dense className="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  {/* ... */}
</Table>
<Table grid dense class="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <!-- ... -->
</Table>

Zebra rows

striped shades every other row and drops the horizontal dividers:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
<Table striped className="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  {/* ... */}
</Table>
<Table striped class="[--gutter:--spacing(6)] sm:[--gutter:--spacing(8)]">
  <!-- ... -->
</Table>

Header emphasis

Headers are muted by default. A text color on the header TableRow makes them as strong as the body:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
<TableHead>
  <TableRow className="text-zinc-950 dark:text-white">
    <TableHeader>Invoice</TableHeader>
    {/* ... */}
  </TableRow>
</TableHead>
<TableHead>
  <TableRow class="text-zinc-950 dark:text-white">
    <TableHeader>Invoice</TableHeader>
    <!-- ... -->
  </TableRow>
</TableHead>

Interaction

Clickable rows

Give a TableRow an href and the whole row becomes a link, with hover and focus styles. title names the link for assistive technology, and target works as on an anchor:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
INV-2039Kumo LabsSep 9, 2026$860.00
INV-2038Baobab FreightSep 4, 2026$12,940.00
INV-2037Solana StudioAug 29, 2026$2,315.75
<TableBody>
  {invoices.map((invoice) => (
    <TableRow key={invoice.id} href={`/invoices/${invoice.id}`} title={`Open ${invoice.id}`}>
      <TableCell className="font-medium">{invoice.id}</TableCell>
      <TableCell>{invoice.company}</TableCell>
      <TableCell className="text-zinc-500">{invoice.date}</TableCell>
      <TableCell className="text-right tabular-nums">{invoice.amount}</TableCell>
    </TableRow>
  ))}
</TableBody>
<TableBody>
  <TableRow
    v-for="invoice in invoices"
    :key="invoice.id"
    :href="`/invoices/${invoice.id}`"
    :title="`Open ${invoice.id}`"
  >
    <TableCell class="font-medium">{{ invoice.id }}</TableCell>
    <TableCell>{{ invoice.company }}</TableCell>
    <TableCell class="text-zinc-500">{{ invoice.date }}</TableCell>
    <TableCell class="text-right tabular-nums">{{ invoice.amount }}</TableCell>
  </TableRow>
</TableBody>

Row actions

Put a plain Dropdown in the last cell for actions on a single record. The negative vertical margin keeps the button from making the row taller than its text:

InvoiceCustomerAmountActions
INV-2041Lumen Health$4,200.00
INV-2040Atelier Nord$1,180.50
INV-2039Kumo Labs$860.00
INV-2038Baobab Freight$12,940.00
INV-2037Solana Studio$2,315.75
<TableCell>
  <div className="-my-1.5 flex justify-end">
    <Dropdown>
      <DropdownButton plain aria-label={`Actions for ${invoice.id}`}>
        <EllipsisHorizontalIcon />
      </DropdownButton>
      <DropdownMenu anchor="bottom end">
        <DropdownItem>Download PDF</DropdownItem>
        <DropdownItem>Send reminder</DropdownItem>
        <DropdownItem>Void invoice</DropdownItem>
      </DropdownMenu>
    </Dropdown>
  </div>
</TableCell>
<script setup lang="ts">
import Dropdown from '@/components/dropdown.vue'
import DropdownButton from '@/components/dropdown-button.vue'
import DropdownItem from '@/components/dropdown-item.vue'
import DropdownMenu from '@/components/dropdown-menu.vue'
import TableCell from '@/components/table-cell.vue'
import { EllipsisHorizontalIcon } from '@heroicons/vue/16/solid'

defineProps<{ invoice: { id: string } }>()
</script>

<template>
  <TableCell>
    <div class="-my-1.5 flex justify-end">
      <Dropdown>
        <DropdownButton plain :aria-label="`Actions for ${invoice.id}`">
          <EllipsisHorizontalIcon />
        </DropdownButton>
        <DropdownMenu anchor="bottom end">
          <DropdownItem>Download PDF</DropdownItem>
          <DropdownItem>Send reminder</DropdownItem>
          <DropdownItem>Void invoice</DropdownItem>
        </DropdownMenu>
      </Dropdown>
    </div>
  </TableCell>
</template>

Composition

Rich cells

Cells accept any content. Stack a name over a secondary line, lead with an Avatar, and show state with a Badge:

CustomerDueStatusAmount
Lumen Health
arthur@lumenhealth.io
Oct 2, 2026Overdue$4,200.00
Atelier Nord
leonel@ateliernord.fr
Sep 29, 2026Open$1,180.50
Kumo Labs
zakaria@kumolabs.jp
Sep 23, 2026Paid$860.00
Baobab Freight
damilola@baobabfreight.sn
Sep 18, 2026Paid$12,940.00
Solana Studio
fabrice@solana.studio
Sep 12, 2026Paid$2,315.75
<TableBody>
  {invoices.map((invoice) => (
    <TableRow key={invoice.id}>
      <TableCell>
        <div className="flex items-center gap-3">
          <Avatar src={invoice.avatarUrl} className="size-9" />
          <div>
            <div className="font-medium">{invoice.company}</div>
            <div className="text-zinc-500">{invoice.email}</div>
          </div>
        </div>
      </TableCell>
      <TableCell className="text-zinc-500">{invoice.due}</TableCell>
      <TableCell>
        <Badge color={statusColor[invoice.status]}>{invoice.status}</Badge>
      </TableCell>
      <TableCell className="text-right tabular-nums">{invoice.amount}</TableCell>
    </TableRow>
  ))}
</TableBody>
<TableBody>
  <TableRow v-for="invoice in invoices" :key="invoice.id">
    <TableCell>
      <div class="flex items-center gap-3">
        <Avatar :src="invoice.avatarUrl" class="size-9" />
        <div>
          <div class="font-medium">{{ invoice.company }}</div>
          <div class="text-zinc-500">{{ invoice.email }}</div>
        </div>
      </div>
    </TableCell>
    <TableCell class="text-zinc-500">{{ invoice.due }}</TableCell>
    <TableCell>
      <Badge :color="statusColor[invoice.status]">{{ invoice.status }}</Badge>
    </TableCell>
    <TableCell class="text-right tabular-nums">{{ invoice.amount }}</TableCell>
  </TableRow>
</TableBody>

Paginated

Long lists page through a Pagination placed under the table. On the first page, PaginationPrevious without an href or an onClick renders disabled:

InvoiceCustomerIssuedAmount
INV-2041Lumen HealthSep 18, 2026$4,200.00
INV-2040Atelier NordSep 15, 2026$1,180.50
<Table>{/* ... */}</Table>

<Pagination className="mt-4">
  <PaginationPrevious />
  <PaginationList>
    <PaginationPage href="?page=1" current>
      1
    </PaginationPage>
    <PaginationPage href="?page=2">2</PaginationPage>
    <PaginationPage href="?page=3">3</PaginationPage>
    <PaginationGap />
    <PaginationPage href="?page=12">12</PaginationPage>
  </PaginationList>
  <PaginationNext href="?page=2" />
</Pagination>
<Table><!-- ... --></Table>

<Pagination class="mt-4">
  <PaginationPrevious />
  <PaginationList>
    <PaginationPage href="?page=1" current>1</PaginationPage>
    <PaginationPage href="?page=2">2</PaginationPage>
    <PaginationPage href="?page=3">3</PaginationPage>
    <PaginationGap />
    <PaginationPage href="?page=12">12</PaginationPage>
  </PaginationList>
  <PaginationNext href="?page=2" />
</Pagination>

Inside a dialog

The dialog panel sets --gutter to its own padding, so a bleed table inside DialogBody fits it without extra classes:

<Dialog size="2xl" open={isOpen} onClose={setIsOpen}>
  <DialogTitle>INV-2041</DialogTitle>
  <DialogDescription>Billed to Lumen Health on Sep 18, 2026.</DialogDescription>
  <DialogBody>
    <Table bleed dense>
      {/* ... */}
    </Table>
  </DialogBody>
  <DialogActions>
    <Button plain onClick={() => setIsOpen(false)}>
      Close
    </Button>
    <Button onClick={() => setIsOpen(false)}>Download PDF</Button>
  </DialogActions>
</Dialog>
<Dialog size="2xl" :open="isOpen" @close="isOpen = false">
  <DialogTitle>INV-2041</DialogTitle>
  <DialogDescription>Billed to Lumen Health on Sep 18, 2026.</DialogDescription>
  <DialogBody>
    <Table bleed dense>
      <!-- ... -->
    </Table>
  </DialogBody>
  <DialogActions>
    <Button plain @click="isOpen = false">Close</Button>
    <Button @click="isOpen = false">Download PDF</Button>
  </DialogActions>
</Dialog>