HextaUI

Table

A responsive table with a surface style, wrapping or compact cells, sticky headers, pinned columns and scroll hints.

InvoiceClientStatusAmount
INV-0418NorthwindPaid$2,400.00
INV-0419GlobexPending$860.00
INV-0420InitechPaid$1,320.50
INV-0421UmbrellaOverdue$415.00
Total$4,995.50
import { Badge, BadgeDot } from "@/components/ui/badge"
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const invoices = [
  { id: "INV-0418", client: "Northwind", status: "Paid", amount: 2400 },
  { id: "INV-0419", client: "Globex", status: "Pending", amount: 860 },
  { id: "INV-0420", client: "Initech", status: "Paid", amount: 1320.5 },
  { id: "INV-0421", client: "Umbrella", status: "Overdue", amount: 415 },
]

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
})

const tones = {
  Paid: "success",
  Pending: "info",
  Overdue: "destructive",
} as const

export function TableDemo() {
  const total = invoices.reduce((sum, invoice) => sum + invoice.amount, 0)

  return (
    <Table variant="surface" className="min-w-md">
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Client</TableHead>
          <TableHead>Status</TableHead>
          <TableHead align="end">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.id}>
            <TableHead scope="row">{invoice.id}</TableHead>
            <TableCell>{invoice.client}</TableCell>
            <TableCell>
              <Badge variant={tones[invoice.status as keyof typeof tones]}>
                <BadgeDot />
                {invoice.status}
              </Badge>
            </TableCell>
            <TableCell align="end">{currency.format(invoice.amount)}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell align="end">{currency.format(total)}</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}
pnpm dlx shadcn@latest add https://hextaui.com/r/table.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
<Table variant="surface">
  <TableHeader>
    <TableRow>
      <TableHead>Invoice</TableHead>
      <TableHead align="end">Amount</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableHead scope="row">INV-0418</TableHead>
      <TableCell align="end">$2,400.00</TableCell>
    </TableRow>
  </TableBody>
</Table>

Use <TableHead scope="row"> for the cell that names each row. It reads as the row's header to screen readers and gets a stronger text color. For sorting, filtering and selection, build on <DataTable />.

Table
├── TableCaption
├── TableHeader
│   └── TableRow
│       └── TableHead
├── TableBody
│   └── TableRow
│       └── TableCell
└── TableFooter
    └── TableRow
        └── TableCell

Default

The default variant has no surface, just hairlines between rows, for tables that sit inside a card or a page section. <TableCaption /> describes the table below it.

Recent releases.
VersionReleasedChanges
2.4.0Oct 2, 202618
2.3.1Sep 21, 20264
2.3.0Sep 12, 202623
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const releases = [
  { version: "2.4.0", date: "Oct 2, 2026", changes: 18 },
  { version: "2.3.1", date: "Sep 21, 2026", changes: 4 },
  { version: "2.3.0", date: "Sep 12, 2026", changes: 23 },
]

export function TablePlain() {
  return (
    <Table>
      <TableCaption>Recent releases.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Version</TableHead>
          <TableHead>Released</TableHead>
          <TableHead align="end">Changes</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {releases.map((release) => (
          <TableRow key={release.version}>
            <TableHead scope="row">{release.version}</TableHead>
            <TableCell>{release.date}</TableCell>
            <TableCell align="end">{release.changes}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Compact

size="sm" tightens row height and padding for dense data.

RegionLatencyUptime
us-east-142 ms99.99%
eu-west-238 ms99.98%
ap-south-171 ms99.95%
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const rows = [
  { region: "us-east-1", latency: "42 ms", uptime: "99.99%" },
  { region: "eu-west-2", latency: "38 ms", uptime: "99.98%" },
  { region: "ap-south-1", latency: "71 ms", uptime: "99.95%" },
]

export function TableSizes() {
  return (
    <Table variant="surface" size="sm">
      <TableHeader>
        <TableRow>
          <TableHead>Region</TableHead>
          <TableHead align="end">Latency</TableHead>
          <TableHead align="end">Uptime</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {rows.map((row) => (
          <TableRow key={row.region}>
            <TableCell>
              <span className="font-mono text-xs">{row.region}</span>
            </TableCell>
            <TableCell align="end">{row.latency}</TableCell>
            <TableCell align="end">{row.uptime}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Wrapping text

Cells stay on one line by default, which suits data. wrap lets them wrap and aligns rows to the top, for prose like glossaries or API references. Pair it with table-fixed and column widths.

TermMeaning
Concentric radiusAn inner corner radius equal to the outer radius minus the gap between the two edges, so nested shapes look like they belong together.
HairlineThe thinnest line the screen can draw: 1px on standard displays and two thirds of a pixel on high-density ones.
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const terms = [
  {
    term: "Concentric radius",
    meaning:
      "An inner corner radius equal to the outer radius minus the gap between the two edges, so nested shapes look like they belong together.",
  },
  {
    term: "Hairline",
    meaning:
      "The thinnest line the screen can draw: 1px on standard displays and two thirds of a pixel on high-density ones.",
  },
]

export function TableWrap() {
  return (
    <Table variant="surface" wrap className="table-fixed">
      <TableHeader>
        <TableRow>
          <TableHead className="w-1/3">Term</TableHead>
          <TableHead>Meaning</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {terms.map((row) => (
          <TableRow key={row.term}>
            <TableHead scope="row">{row.term}</TableHead>
            <TableCell>
              <span className="text-muted-foreground">{row.meaning}</span>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Wide tables

Tables wider than their container scroll sideways. Soft fades show there's more on either side, and the scroll area becomes focusable so keyboard users can scroll it with the arrow keys. Turn the fades off with scrollFade={false}.

MetricJanFebMarAprMayJunJulAugSepOctNovDec
Visitors8129041,0219901,2031,3401,2881,4101,5021,6221,5901,711
Sign-ups415266597183809297110104121
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
]

const series = [
  {
    name: "Visitors",
    values: [
      812, 904, 1021, 990, 1203, 1340, 1288, 1410, 1502, 1622, 1590, 1711,
    ],
  },
  {
    name: "Sign-ups",
    values: [41, 52, 66, 59, 71, 83, 80, 92, 97, 110, 104, 121],
  },
]

export function TableScroll() {
  return (
    <Table variant="surface" className="w-max min-w-full">
      <TableHeader>
        <TableRow>
          <TableHead>Metric</TableHead>
          {months.map((month) => (
            <TableHead key={month} align="end">
              {month}
            </TableHead>
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {series.map((row) => (
          <TableRow key={row.name}>
            <TableHead scope="row">{row.name}</TableHead>
            {row.values.map((value, index) => (
              <TableCell key={months[index]} align="end">
                {value.toLocaleString("en-US")}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Pinned column

pinned="start" keeps the first column in view while the rest scrolls, and pinnedEdge shows a shadow once content slides under it.

TeamQ1 2025Q2 2025Q3 2025Q4 2025Q1 2026Q2 2026Q3 2026
Design systems12141518212224
Platform31303335343840
Growth891110121516
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const quarters = [
  "Q1 2025",
  "Q2 2025",
  "Q3 2025",
  "Q4 2025",
  "Q1 2026",
  "Q2 2026",
  "Q3 2026",
]

const teams = [
  { name: "Design systems", values: [12, 14, 15, 18, 21, 22, 24] },
  { name: "Platform", values: [31, 30, 33, 35, 34, 38, 40] },
  { name: "Growth", values: [8, 9, 11, 10, 12, 15, 16] },
]

export function TablePinned() {
  return (
    <Table variant="surface" className="w-max min-w-full">
      <TableHeader>
        <TableRow>
          <TableHead pinned="start" pinnedEdge>
            Team
          </TableHead>
          {quarters.map((quarter) => (
            <TableHead key={quarter} align="end">
              {quarter}
            </TableHead>
          ))}
        </TableRow>
      </TableHeader>
      <TableBody>
        {teams.map((team) => (
          <TableRow key={team.name}>
            <TableHead scope="row" pinned="start" pinnedEdge>
              {team.name}
            </TableHead>
            {team.values.map((value, index) => (
              <TableCell key={quarters[index]} align="end">
                {value}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

stickyHeader keeps the header in view in a height-limited table, and adds a hairline under it once rows scroll beneath.

EventTypeWhen
evt_400deploy2 min ago
evt_401rollback9 min ago
evt_402scale16 min ago
evt_403restart23 min ago
evt_404deploy30 min ago
evt_405rollback37 min ago
evt_406scale44 min ago
evt_407restart51 min ago
evt_408deploy58 min ago
evt_409rollback65 min ago
evt_40ascale72 min ago
evt_40brestart79 min ago
evt_40cdeploy86 min ago
evt_40drollback93 min ago
evt_40escale100 min ago
evt_40frestart107 min ago
evt_410deploy114 min ago
evt_411rollback121 min ago
evt_412scale128 min ago
evt_413restart135 min ago
evt_414deploy142 min ago
evt_415rollback149 min ago
evt_416scale156 min ago
evt_417restart163 min ago
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const events = Array.from({ length: 24 }, (_, index) => ({
  id: `evt_${(1024 + index).toString(16)}`,
  type: ["deploy", "rollback", "scale", "restart"][index % 4],
  minutes: index * 7 + 2,
}))

export function TableSticky() {
  return (
    <Table variant="surface" stickyHeader containerClassName="max-h-72">
      <TableHeader>
        <TableRow>
          <TableHead>Event</TableHead>
          <TableHead>Type</TableHead>
          <TableHead align="end">When</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {events.map((event) => (
          <TableRow key={event.id}>
            <TableCell>
              <span className="font-mono text-xs">{event.id}</span>
            </TableCell>
            <TableCell>{event.type}</TableCell>
            <TableCell align="end">{event.minutes} min ago</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Right to left

Alignment, pinned columns and scroll fades follow the reading direction.

الفريقالأعضاءالميزانية
التصميم8١٢٬٠٠٠
الهندسة21٤٨٬٥٠٠
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const rows = [
  { name: "التصميم", members: 8, budget: "١٢٬٠٠٠" },
  { name: "الهندسة", members: 21, budget: "٤٨٬٥٠٠" },
]

export function TableRtl() {
  return (
    <div dir="rtl" className="w-full">
      <Table variant="surface">
        <TableHeader>
          <TableRow>
            <TableHead>الفريق</TableHead>
            <TableHead align="end">الأعضاء</TableHead>
            <TableHead align="end">الميزانية</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.name}>
              <TableHead scope="row">{row.name}</TableHead>
              <TableCell align="end">{row.members}</TableCell>
              <TableCell align="end">{row.budget}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}
KeyAction
TabFocuses the scroll area when the table overflows, then the links and buttons inside it.
←→Scrolls a focused, overflowing table sideways.
↑↓Scrolls a focused table with a sticky header up and down.
  • The parts render native table elements, so screen readers announce rows, columns and headers without extra roles.
  • Mark the cell that names a row with <TableHead scope="row"> so each value is read with its row and column.
  • The scroll area only joins the tab order while it overflows, so tables that fit don't add an extra stop.

Every part renders its native element and accepts its attributes.

PropTypeDefault
variantsurface adds a rounded hairline frame and a muted header band.
"default" | "surface""default"
size
"sm" | "default""default"
wrapLet cell text wrap and align rows to the top.
booleanfalse
scrollFadeFade the edges that have more content to scroll to. Skipped when columns are pinned.
booleantrue
stickyHeaderKeep the header in view. Give the container a max height through containerClassName.
booleanfalse
containerClassNameClasses for the scroll container.
string–
containerRefRef to the scroll container.
Ref<HTMLDivElement>–
AttributeDescription
data-slot="table-frame"The outer frame, with data-variant, data-size and data-wrap.
data-slot="table-container"The scroll container.
data-overflowingPresent on the container while its content overflows. It's focusable then.
data-scrolled-start / data-scrolled-endPresent while there's content hidden before or after the visible area.
data-scrolled-topPresent once a sticky-header table has scrolled.
--table-bgRow background. Follows a surrounding card or popover.
--table-head-bgHeader band background in the surface variant.
--table-cell-px / --table-cell-pyCell padding. Set by size.
PropTypeDefault
scopeUse row for the cell that names a row. Inside the body it gets cell padding and foreground text.
"col" | "row"–
alignend also switches to tabular numbers so digits line up.
"start" | "center" | "end""start"
pinnedKeep the column in view while the table scrolls sideways. Set the same value on its header and every cell.
"start" | "end"–
pinnedEdgeDraw a soft shadow on the inner edge of the last pinned column while content is hidden behind it.
booleanfalse
AttributeDescription
data-slot="table-head"Target header cells in CSS.
data-alignThe alignment.
data-pinnedThe pinned side.
PropTypeDefault
alignend also switches to tabular numbers so digits line up.
"start" | "center" | "end""start"
pinnedKeep the column in view while the table scrolls sideways. Set the same value on its header and every cell.
"start" | "end"–
pinnedEdgeDraw a soft shadow on the inner edge of the last pinned column while content is hidden behind it.
booleanfalse
AttributeDescription
data-slot="table-cell"Target cells in CSS.
data-alignThe alignment.
data-pinnedThe pinned side.
AttributeDescription
data-slot="table-row"Target rows in CSS.
data-state="selected"Set it to highlight a selected row.
AttributeDescription
data-slot="table-header"The thead.
data-slot="table-body"The tbody.
data-slot="table-footer"The tfoot, on the header band color.
data-slot="table-caption"Below the table. In the surface variant it sits inside the frame.