Free shadcn/ui table component for React — the styled

primitive plus sortable, selectable, sticky-header, paginated, and expandable examples, all in plain React state (no TanStack). The examples official shadcn never shipped.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

utils

Props

No props documented yet.

Copied!
components/ui/table.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

function Table({ className, ...props }: React.ComponentProps<"table">) {
  return (
    <div
      data-slot="table-container"
      className="relative w-full overflow-x-auto"
    >
      <table
        data-slot="table"
        className={cn("w-full caption-bottom text-sm", className)}
        {...props}
      />
    </div>
  )
}

function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
  return (
    <thead
      data-slot="table-header"
      className={cn("[&_tr]:border-b", className)}
      {...props}
    />
  )
}

function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
  return (
    <tbody
      data-slot="table-body"
      className={cn("[&_tr:last-child]:border-0", className)}
      {...props}
    />
  )
}

function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
  return (
    <tfoot
      data-slot="table-footer"
      className={cn(
        "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
        className
      )}
      {...props}
    />
  )
}

function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
  return (
    <tr
      data-slot="table-row"
      className={cn(
        "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
        className
      )}
      {...props}
    />
  )
}

function TableHead({ className, ...props }: React.ComponentProps<"th">) {
  return (
    <th
      data-slot="table-head"
      className={cn(
        "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
        className
      )}
      {...props}
    />
  )
}

function TableCell({ className, ...props }: React.ComponentProps<"td">) {
  return (
    <td
      data-slot="table-cell"
      className={cn(
        "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
        className
      )}
      {...props}
    />
  )
}

function TableCaption({
  className,
  ...props
}: React.ComponentProps<"caption">) {
  return (
    <caption
      data-slot="table-caption"
      className={cn("text-muted-foreground mt-4 text-sm", className)}
      {...props}
    />
  )
}

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

Examples

Table

The canonical invoices table — caption, header, body, and a footer totals row with right-aligned currency.

Packages

table

Props

No props documented yet.

Copied!
components/ui/table-demo-01.tsx
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 TableDemo01() {
  return (
    <Table>
      <TableCaption>A list of your recent invoices.</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[100px]">Invoice</TableHead>
          <TableHead>Status</TableHead>
          <TableHead>Method</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {invoices.map((invoice) => (
          <TableRow key={invoice.invoice}>
            <TableCell className="font-medium">{invoice.invoice}</TableCell>
            <TableCell>{invoice.status}</TableCell>
            <TableCell>{invoice.method}</TableCell>
            <TableCell className="text-right tabular-nums">{invoice.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow>
          <TableCell colSpan={3}>Total</TableCell>
          <TableCell className="text-right tabular-nums">$1,750.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}

Striped rows

Zebra striping with the `odd:bg-muted/50` utility on each row — no extra markup.

Packages

table

Props

No props documented yet.

Copied!
components/ui/table-striped-01.tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const people = [
  { name: "Ada Lovelace", title: "Mathematician", team: "Engineering" },
  { name: "Alan Turing", title: "Computer Scientist", team: "Research" },
  { name: "Grace Hopper", title: "Rear Admiral", team: "Engineering" },
  { name: "Katherine Johnson", title: "Mathematician", team: "Research" },
  { name: "Margaret Hamilton", title: "Software Engineer", team: "Apollo" },
  { name: "Hedy Lamarr", title: "Inventor", team: "Comms" },
]

export default function TableStriped01() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Name</TableHead>
          <TableHead>Title</TableHead>
          <TableHead>Team</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {people.map((person) => (
          <TableRow
            key={person.name}
            className="odd:bg-muted/50 odd:hover:bg-muted/50"
          >
            <TableCell className="font-medium">{person.name}</TableCell>
            <TableCell>{person.title}</TableCell>
            <TableCell>{person.team}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Selectable rows

A checkbox column with an **indeterminate select-all** — plain React state, no TanStack. Uses [Checkbox](/components/checkbox).

Packages

table
checkbox

Props

No props documented yet.

Copied!
components/ui/table-selectable-01.tsx
"use client"

import * as React from "react"

import { Checkbox } from "@/components/ui/checkbox"
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const people = [
  { id: "1", name: "Ada Lovelace", email: "[email protected]", role: "Owner" },
  { id: "2", name: "Alan Turing", email: "[email protected]", role: "Member" },
  { id: "3", name: "Grace Hopper", email: "[email protected]", role: "Member" },
  { id: "4", name: "Katherine Johnson", email: "[email protected]", role: "Viewer" },
]

export default function TableSelectable01() {
  const [selected, setSelected] = React.useState<string[]>([])
  const allSelected = selected.length === people.length
  const someSelected = selected.length > 0 && !allSelected

  const toggleAll = (value: boolean) =>
    setSelected(value ? people.map((person) => person.id) : [])
  const toggleOne = (id: string, value: boolean) =>
    setSelected((prev) =>
      value ? [...prev, id] : prev.filter((item) => item !== id)
    )

  return (
    <Table>
      <TableCaption>
        {selected.length} of {people.length} row(s) selected.
      </TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[40px]">
            <Checkbox
              checked={allSelected ? true : someSelected ? "indeterminate" : false}
              onCheckedChange={(value) => toggleAll(value === true)}
              aria-label="Select all"
            />
          </TableHead>
          <TableHead>Name</TableHead>
          <TableHead>Email</TableHead>
          <TableHead>Role</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {people.map((person) => {
          const checked = selected.includes(person.id)
          return (
            <TableRow
              key={person.id}
              data-state={checked ? "selected" : undefined}
            >
              <TableCell>
                <Checkbox
                  checked={checked}
                  onCheckedChange={(value) => toggleOne(person.id, value === true)}
                  aria-label={`Select ${person.name}`}
                />
              </TableCell>
              <TableCell className="font-medium">{person.name}</TableCell>
              <TableCell>{person.email}</TableCell>
              <TableCell>{person.role}</TableCell>
            </TableRow>
          )
        })}
      </TableBody>
    </Table>
  )
}

Sortable headers

Click a column header to sort ascending / descending — client-side sort with plain `useState`.

Packages

lucide-react
table
button

Props

No props documented yet.

Copied!
components/ui/table-sortable-01.tsx
"use client"

import * as React from "react"
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

type Person = { name: string; title: string; age: number }

const data: Person[] = [
  { name: "Ada Lovelace", title: "Mathematician", age: 36 },
  { name: "Alan Turing", title: "Computer Scientist", age: 41 },
  { name: "Grace Hopper", title: "Rear Admiral", age: 85 },
  { name: "Katherine Johnson", title: "Mathematician", age: 101 },
]

type SortKey = keyof Person

export default function TableSortable01() {
  const [sort, setSort] = React.useState<{ key: SortKey; dir: "asc" | "desc" }>({
    key: "name",
    dir: "asc",
  })

  const sorted = React.useMemo(() => {
    return [...data].sort((a, b) => {
      if (a[sort.key] < b[sort.key]) return sort.dir === "asc" ? -1 : 1
      if (a[sort.key] > b[sort.key]) return sort.dir === "asc" ? 1 : -1
      return 0
    })
  }, [sort])

  const toggle = (key: SortKey) =>
    setSort((prev) =>
      prev.key === key
        ? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
        : { key, dir: "asc" }
    )

  function SortHeader({ column, label }: { column: SortKey; label: string }) {
    const active = sort.key === column
    return (
      <Button
        variant="ghost"
        size="sm"
        onClick={() => toggle(column)}
        className="-ml-3 h-8"
      >
        {label}
        {active ? (
          sort.dir === "asc" ? (
            <ArrowUpIcon />
          ) : (
            <ArrowDownIcon />
          )
        ) : (
          <ArrowUpDownIcon className="opacity-50" />
        )}
      </Button>
    )
  }

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>
            <SortHeader column="name" label="Name" />
          </TableHead>
          <TableHead>
            <SortHeader column="title" label="Title" />
          </TableHead>
          <TableHead className="text-right">
            <SortHeader column="age" label="Age" />
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {sorted.map((person) => (
          <TableRow key={person.name}>
            <TableCell className="font-medium">{person.name}</TableCell>
            <TableCell>{person.title}</TableCell>
            <TableCell className="text-right tabular-nums">{person.age}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Status & row actions

Status [badges](/components/badge) in cells plus per-row ghost icon-button actions — the orders-table pattern.

Packages

lucide-react
table
badge
button
dropdown-menu

Props

No props documented yet.

Copied!
components/ui/table-status-01.tsx
"use client"

import { CopyIcon, MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const orders = [
  { id: "#3201", customer: "Ada Lovelace", status: "Paid", total: "$250.00" },
  { id: "#3202", customer: "Alan Turing", status: "Pending", total: "$150.00" },
  { id: "#3203", customer: "Grace Hopper", status: "Refunded", total: "$350.00" },
  { id: "#3204", customer: "Katherine Johnson", status: "Paid", total: "$450.00" },
]

const statusVariant: Record<
  string,
  "default" | "secondary" | "destructive" | "outline"
> = {
  Paid: "default",
  Pending: "secondary",
  Refunded: "destructive",
}

export default function TableStatus01() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Order</TableHead>
          <TableHead>Customer</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-right">Total</TableHead>
          <TableHead className="w-[80px]">
            <span className="sr-only">Actions</span>
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {orders.map((order) => (
          <TableRow key={order.id}>
            <TableCell className="font-medium">{order.id}</TableCell>
            <TableCell>{order.customer}</TableCell>
            <TableCell>
              <Badge variant={statusVariant[order.status]}>{order.status}</Badge>
            </TableCell>
            <TableCell className="text-right tabular-nums">{order.total}</TableCell>
            <TableCell className="text-right">
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    variant="ghost"
                    size="icon"
                    className="data-[state=open]:bg-muted size-8"
                    aria-label="Open menu"
                  >
                    <MoreHorizontalIcon />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem>
                    <PencilIcon />
                    Edit
                  </DropdownMenuItem>
                  <DropdownMenuItem>
                    <CopyIcon />
                    Duplicate
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem variant="destructive">
                    <Trash2Icon />
                    Delete
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

Sticky header

A scrollable table whose header stays pinned with `position: sticky` on the header cells.

Packages

table

Props

No props documented yet.

Copied!
components/ui/table-sticky-01.tsx
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const rows = Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  email: `user${i + 1}@example.com`,
  joined: `2026-${String((i % 12) + 1).padStart(2, "0")}-12`,
}))

export default function TableSticky01() {
  return (
    <div className="overflow-hidden rounded-md border [&_[data-slot=table-container]]:max-h-[320px]">
      <Table>
        <TableHeader className="[&_th]:bg-background [&_th]:sticky [&_th]:top-0 [&_th]:z-10">
          <TableRow>
            <TableHead className="w-[60px]">#</TableHead>
            <TableHead>Name</TableHead>
            <TableHead>Email</TableHead>
            <TableHead className="text-right">Joined</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.id}>
              <TableCell className="text-muted-foreground">{row.id}</TableCell>
              <TableCell className="font-medium">{row.name}</TableCell>
              <TableCell>{row.email}</TableCell>
              <TableCell className="text-right tabular-nums">
                {row.joined}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}

Pagination

A rows-per-page [Select](/components/select) and prev/next controls slicing the data in state — no TanStack.

Packages

lucide-react
table
button
@designrevision/select

Props

No props documented yet.

Copied!
components/ui/table-pagination-01.tsx
"use client"

import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const data = Array.from({ length: 23 }, (_, i) => ({
  id: i + 1,
  name: `Product ${i + 1}`,
  category: ["Apparel", "Books", "Electronics"][i % 3],
  price: `$${(9.99 + i).toFixed(2)}`,
}))

export default function TablePagination01() {
  const [pageSize, setPageSize] = React.useState(5)
  const [page, setPage] = React.useState(0)

  const pageCount = Math.ceil(data.length / pageSize)
  const start = page * pageSize
  const rows = data.slice(start, start + pageSize)

  React.useEffect(() => {
    setPage(0)
  }, [pageSize])

  return (
    <div className="space-y-3">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead className="w-[60px]">#</TableHead>
            <TableHead>Name</TableHead>
            <TableHead>Category</TableHead>
            <TableHead className="text-right">Price</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {rows.map((row) => (
            <TableRow key={row.id}>
              <TableCell className="text-muted-foreground">{row.id}</TableCell>
              <TableCell className="font-medium">{row.name}</TableCell>
              <TableCell>{row.category}</TableCell>
              <TableCell className="text-right tabular-nums">{row.price}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <div className="flex flex-wrap items-center justify-between gap-4">
        <div className="flex items-center gap-2 text-sm text-muted-foreground">
          <span>Rows per page</span>
          <Select
            value={String(pageSize)}
            onValueChange={(value) => setPageSize(Number(value))}
          >
            <SelectTrigger className="h-8 w-[70px]">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {[5, 10, 20].map((n) => (
                <SelectItem key={n} value={String(n)}>
                  {n}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex items-center gap-2">
          <span className="text-sm text-muted-foreground">
            Page {page + 1} of {pageCount}
          </span>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => setPage((p) => Math.max(0, p - 1))}
            disabled={page === 0}
            aria-label="Previous page"
          >
            <ChevronLeftIcon />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="size-8"
            onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
            disabled={page >= pageCount - 1}
            aria-label="Next page"
          >
            <ChevronRightIcon />
          </Button>
        </div>
      </div>
    </div>
  )
}

Expandable rows

Click a row to reveal a detail row beneath it — a collapsible master/detail table in plain state.

Packages

lucide-react
table
button

Props

No props documented yet.

Copied!
components/ui/table-expandable-01.tsx
"use client"

import * as React from "react"
import { ChevronRightIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

const orders = [
  {
    id: "#3201",
    customer: "Ada Lovelace",
    total: "$250.00",
    address: "12 Analytical Way",
    items: ["Mechanical keyboard", "Wireless mouse"],
  },
  {
    id: "#3202",
    customer: "Alan Turing",
    total: "$150.00",
    address: "1 Enigma Road",
    items: ["Leather notebook"],
  },
  {
    id: "#3203",
    customer: "Grace Hopper",
    total: "$350.00",
    address: "9 Compiler Street",
    items: ["27\" monitor", "USB-C cable", "Laptop stand"],
  },
]

export default function TableExpandable01() {
  const [expanded, setExpanded] = React.useState<string | null>(null)

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead className="w-[40px]" />
          <TableHead>Order</TableHead>
          <TableHead>Customer</TableHead>
          <TableHead className="text-right">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {orders.map((order) => {
          const isOpen = expanded === order.id
          return (
            <React.Fragment key={order.id}>
              <TableRow
                data-state={isOpen ? "selected" : undefined}
                className="cursor-pointer"
                onClick={() => setExpanded(isOpen ? null : order.id)}
              >
                <TableCell>
                  <Button
                    variant="ghost"
                    size="icon"
                    className="size-7"
                    aria-label={isOpen ? "Collapse row" : "Expand row"}
                  >
                    <ChevronRightIcon
                      className={cn("transition-transform", isOpen && "rotate-90")}
                    />
                  </Button>
                </TableCell>
                <TableCell className="font-medium">{order.id}</TableCell>
                <TableCell>{order.customer}</TableCell>
                <TableCell className="text-right tabular-nums">
                  {order.total}
                </TableCell>
              </TableRow>
              {isOpen && (
                <TableRow className="hover:bg-transparent">
                  <TableCell colSpan={4} className="bg-muted/30 whitespace-normal">
                    <div className="px-2 py-1 text-sm">
                      <p className="text-muted-foreground">
                        Ships to {order.address}
                      </p>
                      <p className="mt-1">Items: {order.items.join(", ")}</p>
                    </div>
                  </TableCell>
                </TableRow>
              )}
            </React.Fragment>
          )
        })}
      </TableBody>
    </Table>
  )
}

Frequently asked questions

Yes — it is the canonical shadcn/ui table: eight styled parts (Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption) over plain semantic HTML, with no dependency. Official ships the primitive and one invoices demo; we add striped, selectable, sortable, status, sticky-header, paginated, and expandable examples.
The Table is the static, styled markup — you bring the rows. A Data Table is the Table driven by TanStack Table (@tanstack/react-table) to add sorting, filtering, global search, pagination, row selection, and column visibility over large datasets with one engine. For moderate data you do not need it — the examples here do sorting, selection, and pagination in plain React state.
Not for most tables. Striping, sticky headers, row selection, click-to-sort, client-side pagination, and expandable rows all work with plain useState — every example here proves it. Reach for TanStack Table when you have large datasets, server-side sorting/filtering/pagination, faceted filters, or column visibility toggles, where its row models save real work.
Hold a { key, dir } sort state, render each header as a button that toggles it, and sort a copy of your data with useMemo before mapping rows. The Sortable headers example is the complete pattern — no library required.
Keep an array of selected ids. Put a [Checkbox](/components/checkbox) in each row bound to whether its id is in the array, and a header checkbox whose checked prop is true / false / "indeterminate" based on how many rows are selected. Set data-state="selected" on the selected TableRow so it highlights. The Selectable rows example shows it.
Put the height on the Table's own scroll container, not an outer div. The primitive wraps the table in an overflow-x-auto container, which (per the CSS spec) becomes the element that scrolls vertically — so set [&_[data-slot=table-container]]:max-h-[320px] on a wrapper, then make the header cells sticky with [&_th]:bg-background [&_th]:sticky [&_th]:top-0. An outer overflow-y-auto div does not work because the inner container is the real scroll box. The Sticky header example demonstrates it.
Right-align numeric columns with text-right on both the TableHead and TableCell, and add tabular-nums so digits line up in a monospace grid. Format currency with Intl.NumberFormat. You can see this in the totals row of the basic example.