Table
Rows and columns for records people scan and compare.
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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
| Prop | Default | Description |
|---|---|---|
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. |
bleed | false | Let the table run to the edge of its --gutter container. |
dense | false | Condensed vertical spacing. |
grid | false | Show vertical grid lines. |
striped | false | Alternate 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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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:
| Invoice | Customer | Contact | Issued | Due | Amount | |
|---|---|---|---|---|---|---|
| INV-2041 | Lumen Health | Arthur Monney | arthur@lumenhealth.io | Sep 18, 2026 | Oct 2, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Leonel Njoya | leonel@ateliernord.fr | Sep 15, 2026 | Sep 29, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Zakaria Labib | zakaria@kumolabs.jp | Sep 9, 2026 | Sep 23, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Damilola Olowookere | damilola@baobabfreight.sn | Sep 4, 2026 | Sep 18, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Fabrice Yopa | fabrice@solana.studio | Aug 29, 2026 | Sep 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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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:
| Endpoint | Requests | p50 | p95 | Errors | Error rate |
|---|---|---|---|---|---|
| GET /v1/invoices | 48,210 | 42 ms | 118 ms | 12 | 0.02% |
| POST /v1/invoices | 6,904 | 96 ms | 240 ms | 31 | 0.45% |
| GET /v1/customers | 22,587 | 38 ms | 104 ms | 4 | 0.02% |
| PATCH /v1/customers/:id | 3,118 | 71 ms | 190 ms | 9 | 0.29% |
| POST /v1/payments | 9,442 | 154 ms | 412 ms | 58 | 0.61% |
| GET /v1/events | 71,036 | 29 ms | 87 ms | 6 | 0.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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 15, 2026 | $1,180.50 |
| INV-2039 | Kumo Labs | Sep 9, 2026 | $860.00 |
| INV-2038 | Baobab Freight | Sep 4, 2026 | $12,940.00 |
| INV-2037 | Solana Studio | Aug 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:
<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:
| Invoice | Customer | Amount | Actions |
|---|---|---|---|
| INV-2041 | Lumen Health | $4,200.00 | |
| INV-2040 | Atelier Nord | $1,180.50 | |
| INV-2039 | Kumo Labs | $860.00 | |
| INV-2038 | Baobab Freight | $12,940.00 | |
| INV-2037 | Solana 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:
| Customer | Due | Status | Amount |
|---|---|---|---|
Lumen Health arthur@lumenhealth.io | Oct 2, 2026 | Overdue | $4,200.00 |
Atelier Nord leonel@ateliernord.fr | Sep 29, 2026 | Open | $1,180.50 |
Kumo Labs zakaria@kumolabs.jp | Sep 23, 2026 | Paid | $860.00 |
Baobab Freight damilola@baobabfreight.sn | Sep 18, 2026 | Paid | $12,940.00 |
Solana Studio fabrice@solana.studio | Sep 12, 2026 | Paid | $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:
| Invoice | Customer | Issued | Amount |
|---|---|---|---|
| INV-2041 | Lumen Health | Sep 18, 2026 | $4,200.00 |
| INV-2040 | Atelier Nord | Sep 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>