Table

A responsive table component.

A list of your recent invoices.
InvoiceStatusMethodAmount
INV001PaidCredit card$250.00
INV002PendingPayPal$150.00
INV003UnpaidBank transfer$350.00
INV004PaidCredit card$450.00
INV005PaidPayPal$550.00
Total$1,750.00
import { Badge } from "@/components/ui/badge"
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const invoices = [
  { invoice: "INV001", status: "Paid", method: "Credit card", amount: "$250.00" },
  { invoice: "INV002", status: "Pending", method: "PayPal", amount: "$150.00" },
  { invoice: "INV003", status: "Unpaid", method: "Bank transfer", amount: "$350.00" },
  { invoice: "INV004", status: "Paid", method: "Credit card", amount: "$450.00" },
  { invoice: "INV005", status: "Paid", method: "PayPal", amount: "$550.00" },
]

export default function TableDemo() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead align="end">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((row) => (
          <TableRow key={row.invoice}>
            <TableHead>{row.invoice}</TableHead>
            <TableCell>
              <Badge variant={row.status === "Unpaid" ? "outline" : "secondary"}>
                {row.status}
              </Badge>
            </TableCell>
            <TableCell>{row.method}</TableCell>
            <TableCell align="end">{row.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell align="end">$1,750.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation.

Copy and paste the following code into your project.

components/ui/table.tsx
"use client"

import { createContext, use } from "react"
import type { ComponentProps } from "react"
import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import {
  color,
  duration,
  ease,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

type TableSection = "header" | "body" | "footer"

const TableSectionContext = createContext<TableSection>("body")

const styles = stylex.create({
  container: {
    "--carbon-table-radius": radius.xl,
    "--carbon-table-panel-radius": `max(0px, calc(var(--carbon-table-radius) - ${space.s1}))`,
    position: "relative",
    width: "100%",
    overflowX: "auto",
    padding: space.s1,
    borderRadius: "var(--carbon-table-radius)",
    backgroundColor: color.tray,
  },
  table: {
    width: "100%",
    borderCollapse: "separate",
    borderSpacing: 0,
    captionSide: "bottom",
    isolation: "isolate",
    color: color.foreground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontVariantNumeric: "tabular-nums",
  },
  body: {
    position: "relative",
    "::before": {
      content: "''",
      position: "absolute",
      inset: 0,
      zIndex: -1,
      borderRadius: "var(--carbon-table-panel-radius)",
      backgroundColor: color.card,
      boxShadow: shadow.raised,
      pointerEvents: "none",
    },
  },
  footer: { fontWeight: 500 },
  bodyRow: {
    "--carbon-table-row-bg": { default: color.transparent, ":hover": color.itemHighlightBg },
    "--carbon-table-seam": { default: "1px", ":last-child": "0px" },
    "--carbon-table-corner-start": {
      default: "0px",
      ":first-child": "var(--carbon-table-panel-radius)",
    },
    "--carbon-table-corner-end": {
      default: "0px",
      ":last-child": "var(--carbon-table-panel-radius)",
    },
  },
  cell: {
    paddingInline: space.s3,
    paddingBlock: space.s2_5,
    textAlign: "start",
    verticalAlign: "middle",
    whiteSpace: "nowrap",
  },
  bodyCell: {
    backgroundColor: "var(--carbon-table-row-bg)",
    borderBlockEndWidth: "var(--carbon-table-seam)",
    borderBlockEndStyle: "solid",
    borderBlockEndColor: color.border,
    borderStartStartRadius: { default: null, ":first-child": "var(--carbon-table-corner-start)" },
    borderStartEndRadius: { default: null, ":last-child": "var(--carbon-table-corner-start)" },
    borderEndStartRadius: { default: null, ":first-child": "var(--carbon-table-corner-end)" },
    borderEndEndRadius: { default: null, ":last-child": "var(--carbon-table-corner-end)" },
    transitionProperty: "background-color",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  head: {
    paddingBlock: space.s2,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    color: color.mutedForeground,
  },
  rowHead: { fontWeight: 500 },
  caption: {
    paddingInline: space.s3,
    paddingBlockStart: space.s3,
    paddingBlockEnd: space.s2,
    color: color.mutedForeground,
    fontSize: text.xs,
    lineHeight: leading.xs,
    textAlign: "start",
  },
})

const aligns = stylex.create({
  start: { textAlign: "start" },
  center: { textAlign: "center" },
  end: { textAlign: "end" },
})

const tableStyles = {
  container: styles.container,
  table: styles.table,
  body: styles.body,
  footer: styles.footer,
  bodyRow: styles.bodyRow,
  cell: styles.cell,
  bodyCell: styles.bodyCell,
  head: styles.head,
  caption: styles.caption,
  aligns,
}

type TableAlign = keyof typeof aligns

type Overrides = { xstyle?: StyleXStyles; className?: string }

type TableProps = useRender.ComponentProps<"table"> &
  Overrides & {
    containerProps?: ComponentProps<"div"> & Overrides
  }

function Table({ render, ref, style, xstyle, className, containerProps, ...props }: TableProps) {
  const {
    xstyle: containerXstyle,
    className: containerClassName,
    style: containerStyle,
    ...containerRest
  } = containerProps ?? {}
  const containerSx = stylex.props(styles.container, containerXstyle)
  const sx = stylex.props(styles.table, xstyle)
  const table = useRender({
    defaultTagName: "table",
    render,
    ref,
    props: {
      "data-slot": "table",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
  return (
    <div
      data-slot="table-container"
      {...containerSx}
      {...containerRest}
      className={clsx(containerSx.className, containerClassName)}
      style={{ ...containerSx.style, ...containerStyle }}
    >
      {table}
    </div>
  )
}

type SectionProps<T extends "thead" | "tbody" | "tfoot"> = useRender.ComponentProps<T> & Overrides

function TableHeader({ render, ref, style, xstyle, className, ...props }: SectionProps<"thead">) {
  const sx = stylex.props(xstyle)
  const element = useRender({
    defaultTagName: "thead",
    render,
    ref,
    props: {
      "data-slot": "table-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
  return <TableSectionContext value="header">{element}</TableSectionContext>
}

function TableBody({ render, ref, style, xstyle, className, ...props }: SectionProps<"tbody">) {
  const sx = stylex.props(styles.body, xstyle)
  const element = useRender({
    defaultTagName: "tbody",
    render,
    ref,
    props: {
      "data-slot": "table-body",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
  return <TableSectionContext value="body">{element}</TableSectionContext>
}

function TableFooter({ render, ref, style, xstyle, className, ...props }: SectionProps<"tfoot">) {
  const sx = stylex.props(styles.footer, xstyle)
  const element = useRender({
    defaultTagName: "tfoot",
    render,
    ref,
    props: {
      "data-slot": "table-footer",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
  return <TableSectionContext value="footer">{element}</TableSectionContext>
}

function TableRow({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"tr"> & Overrides) {
  const section = use(TableSectionContext)
  const sx = stylex.props(section === "body" && styles.bodyRow, xstyle)
  return useRender({
    defaultTagName: "tr",
    render,
    ref,
    props: {
      "data-slot": "table-row",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

type CellProps<T extends "th" | "td"> = Omit<useRender.ComponentProps<T>, "align"> &
  Overrides & {
    align?: TableAlign
  }

function TableHead({
  align = "start",
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: CellProps<"th">) {
  const section = use(TableSectionContext)
  const inBody = section === "body"
  const sx = stylex.props(
    styles.cell,
    inBody ? [styles.bodyCell, styles.rowHead] : styles.head,
    aligns[align],
    xstyle,
  )
  return useRender({
    defaultTagName: "th",
    render,
    ref,
    props: {
      "data-slot": "table-head",
      scope: inBody ? "row" : "col",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function TableCell({
  align = "start",
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: CellProps<"td">) {
  const section = use(TableSectionContext)
  const sx = stylex.props(styles.cell, section === "body" && styles.bodyCell, aligns[align], xstyle)
  return useRender({
    defaultTagName: "td",
    render,
    ref,
    props: {
      "data-slot": "table-cell",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function TableCaption({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"caption"> & Overrides) {
  const sx = stylex.props(styles.caption, xstyle)
  return useRender({
    defaultTagName: "caption",
    render,
    ref,
    props: {
      "data-slot": "table-caption",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
  tableStyles,
  type TableAlign,
}

Update the import paths to match your project setup.

Usage

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
<Table>
  <TableCaption>A list of your recent invoices.</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead>Invoice</TableHead>
      <TableHead>Status</TableHead>
      <TableHead align="end">Amount</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>INV001</TableCell>
      <TableCell>Paid</TableCell>
      <TableCell align="end">$250.00</TableCell>
    </TableRow>
  </TableBody>
</Table>

Composition

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

The table is drawn on a tray. TableBody is a raised panel inside it, and TableHeader, TableFooter and TableCaption stay on the tray around the panel. Body rows highlight on hover, and a hairline divides them.

Alignment

Set align="end" on the heading and the cells of a column of figures. The table uses tabular figures, so the digits line up.

Actions

Put a menu in the last column. A TableHead in a body row heads that row, as INV001 does in the example above.

MemberRole
OM
Olivia Martinolivia@example.com
Owner
JL
Jackson Leejackson@example.com
Admin
IN
Isabella Nguyenisabella@example.com
Member
import * as stylex from "@stylexjs/stylex"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { HStack, VStack } from "@/components/ui/stack"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { color, leading, text } from "@/theme/tokens.stylex"
import { MoreHorizontal } from "lucide-react"

const members = [
  { name: "Olivia Martin", email: "olivia@example.com", initials: "OM", role: "Owner" },
  { name: "Jackson Lee", email: "jackson@example.com", initials: "JL", role: "Admin" },
  { name: "Isabella Nguyen", email: "isabella@example.com", initials: "IN", role: "Member" },
]

const styles = stylex.create({
  name: { fontWeight: 500 },
  email: { color: color.mutedForeground, fontSize: text.xs, lineHeight: leading.xs },
  actions: { width: "1%" },
})

export default function TableActions() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Member</TableHead>
          <TableHead>Role</TableHead>
          <TableHead aria-label="Actions" xstyle={styles.actions} />
        </TableRow>
      </TableHeader>
      <TableBody>
        {members.map((member) => (
          <TableRow key={member.email}>
            <TableCell>
              <HStack gap={3} align="center">
                <Avatar>
                  <AvatarFallback>{member.initials}</AvatarFallback>
                </Avatar>
                <VStack>
                  <span {...stylex.props(styles.name)}>{member.name}</span>
                  <span {...stylex.props(styles.email)}>{member.email}</span>
                </VStack>
              </HStack>
            </TableCell>
            <TableCell>{member.role}</TableCell>
            <TableCell align="end">
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={<Button variant="ghost" size="icon-sm" />}
                  aria-label={`Actions for ${member.name}`}
                >
                  <MoreHorizontal />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem>Change role</DropdownMenuItem>
                  <DropdownMenuItem>Resend invite</DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem variant="destructive">Remove</DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part renders through Base UI's useRender, and takes xstyle, className and inline style alongside every attribute of its element.

Table

Renders a table inside a div that draws the tray and scrolls sideways when the columns don't fit. xstyle, className and render go to the table.

PropTypeDefault
containerPropsComponentProps<"div"> & { xstyle?: StyleXStyles; className?: string }—
renderReactElement | ((props, state) => ReactElement)<table />
xstyleStyleXStyles—
classNamestring—

TableHeader, TableBody, TableFooter

Render thead, tbody and tfoot. Each tells its rows which section they are in, so only body rows are raised, divided and highlighted. TableBody draws the panel behind itself. The footer's text is weight 500.

TableRow

Renders a tr. In the body it highlights on hover and draws a hairline below itself. The last row has no hairline, because the panel's edge is below it.

TableHead

Renders a th. In the header it is a column label in small muted type with scope="col". In a body row it heads that row at weight 500 with scope="row". Pass scope to override either.

PropTypeDefault
align"start" | "center" | "end""start"

TableCell

Renders a td. Cells don't wrap. To wrap one, pass xstyle.

PropTypeDefault
align"start" | "center" | "end""start"

TableCaption

Renders a caption under the table, on the tray.

tableStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
containerThe tray and horizontal scroller
tableThe table: separate borders, type, tabular figures
bodyThe raised panel behind the body
footerThe footer's weight
bodyRowThe variables a body row sets for its cells
cellA cell's padding and alignment
bodyCellA body cell's fill, hairline and corners, read from the row
headA column label's type
captionThe caption
alignsstart, center and end

Data attributes

AttributeOnEffect
data-slot="table-container"the trayTargets the tray from a parent
data-slot="table"the tableTargets the table from a parent
data-slot="table-header"the headerTargets the header from a parent
data-slot="table-body"the bodyTargets the body from a parent
data-slot="table-footer"the footerTargets the footer from a parent
data-slot="table-row"a rowTargets a row from a parent
data-slot="table-head"a headingTargets a heading from a parent
data-slot="table-cell"a cellTargets a cell from a parent
data-slot="table-caption"the captionTargets the caption from a parent

CSS variables

VariableOnEffect
--carbon-table-radiusthe trayThe tray's corner radius, 12px. The panel's is 4px less
--carbon-table-panel-radiusthe trayThe panel's corner, derived from the tray's