Skip to contentVibraUI
Data display

Table

The table parts: a scrolling container, a ruled header, hover rows and a caption.

Vibra rules the header row with the heavier --rule and sets its cells in the 13px label register in muted ink, where shadcn's are foreground on the row hairline; a selected row takes the accent-muted fill, a 2px accent rail and medium weight — three cues, never colour alone. Column titles start at the inline start, a checkbox column trims its inline end, and the rail sits on the row's inline start, so a right-to-left table mirrors without a class of its own. Cells set tabular figures through the theme; right-align a numeric column's title and cells together. Name a table by its caption or, beside a visible heading, with aria-label. A row keeps the hover plane while a menu in it is open. The box a table sits in scrolls it sideways when it is wider than the page, and the arrow keys scroll only what has the focus: while the table overflows, the box is a tab stop and a region named after the table — its aria-labelledby, its aria-label, else its TableCaption, which takes an id of its own for this — with the focus ring drawn inside its edge — or a group of that name when the card or band around it is already a region called the same, so a page never holds two landmarks of one name; while it fits, the box is neither, so it never costs a Tab press that scrolls nothing. It measures itself with a ResizeObserver, so a narrower window or a wider page of rows turns it on. For sorting, filters and paging over a data set, reach for DataTable.

Install

npx shadcn@latest add @vibra/table

Needs the @vibra registry in your components.json — set it up once.

Examples

Sortable columns

Each title is a button and its header cell carries aria-sort; the caption says the order in words.

Selectable rows

A named checkbox per row and one for them all, a selected plane on each ticked row, and the count in a status line.

Rows that open

The order number is one link stretched over its row: a click anywhere opens it, the keyboard meets one link per order.

Row actions

A menu at the end of each row, named for the row it acts on; a reminder that cannot go says why, and voiding asks first.

Expandable rows

A disclosure button per order opens its lines in a row beneath, which stays in the table, hidden, while shut.

Grouped rows

One body per group under a rowgroup header, with the group's subtotal in the amount column.

Scrolls both ways

A long, wide list in a keyboard-scrollable box: the titles stay on top and each SKU stays at the left, both opaque.

Empty

The columns stay and one row across them says why nothing is here and what fills it.

Right to left

An Arabic table: titles from the right, figures on the inline end, and the selected rail on the right.

Props

PropTypeDefaultDescription
TableRow data-state="selected"attribute—Marks the row selected: the accent-muted fill, the rail and the weight.
TableHead aria-sort"ascending" | "descending" | "none"—On a sortable column's title, so a screen reader hears the order where it reads the column; put the sort button inside the cell.
TableHead scope="rowgroup"attribute—Heads a group of rows in its own TableBody; each row in the group is read with the group's name.

Dependencies

Registry

Source

components/ui/table.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Whether a box scrolls: its overflow lets it, and what it holds is bigger than it is. */
function scrolls(box: HTMLElement) {
  const style = getComputedStyle(box)
  const scrollable = (overflow: string) => overflow === "auto" || overflow === "scroll"
  return (
    (scrollable(style.overflowX) && box.scrollWidth > box.clientWidth) ||
    (scrollable(style.overflowY) && box.scrollHeight > box.clientHeight)
  )
}

/** The text a name comes from: the elements aria-labelledby names, else aria-label. */
function nameOf(element: Element | null | undefined): string {
  if (!element) return ""
  const ids = element.getAttribute("aria-labelledby")
  if (ids) {
    return ids
      .split(/\s+/)
      .map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim() ?? "")
      .join(" ")
      .trim()
  }
  return element.getAttribute("aria-label")?.trim() ?? ""
}

/**
 * Whether the box, as a region, would carry the very name of the landmark
 * region around it — a card or a band named by the same title as its table —
 * which is two landmarks of one name (axe landmark-unique, on fourteen pages
 * and eight table sections). The box is then a group instead: still a named
 * tab stop, no second landmark.
 */
function sharesEnclosingName(box: HTMLElement, table: HTMLTableElement | null): boolean {
  const own = nameOf(table) || table?.caption?.textContent?.trim() || ""
  const around = box.parentElement?.closest('[role="region"], section[aria-label], section[aria-labelledby]')
  return Boolean(own && around && nameOf(around) === own)
}

/** The id a TableCaption takes when it is given none, so the scroll region can be named by it. */
const CaptionIdContext = React.createContext<string | undefined>(undefined)

/**
 * The table, in a box that scrolls it sideways when it is wider than the page.
 * A box that scrolls is one the keyboard has to reach — the arrow keys scroll
 * only what has the focus — so while the table overflows, the box is a tab
 * stop and a region named after the table (its aria-labelledby, its
 * aria-label, else its caption), with a focus ring drawn inside its edge where
 * no ancestor's overflow can clip it — or a group of that name, when the card
 * or band around it is already a region called the same. While it fits, it is
 * neither: a tab stop that scrolls nothing is one more press for nothing.
 */
function Table({
  className,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  ...props
}: React.ComponentProps<"table">) {
  const boxRef = React.useRef<HTMLDivElement>(null)
  const captionId = React.useId()
  const [scrolling, setScrolling] = React.useState(false)
  const [captionedBy, setCaptionedBy] = React.useState<string | undefined>(undefined)
  const [sharesName, setSharesName] = React.useState(false)

  React.useEffect(() => {
    const box = boxRef.current
    if (!box) return
    // Measured after mount and on every resize, of the box or of the table in
    // it: a page of wider rows, a column shown, a narrower window.
    const measure = () => {
      const table = box.firstElementChild as HTMLTableElement | null
      setScrolling(scrolls(box))
      setCaptionedBy(table?.caption?.id || undefined)
      setSharesName(sharesEnclosingName(box, table))
    }
    measure()
    if (typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(measure)
    observer.observe(box)
    if (box.firstElementChild) observer.observe(box.firstElementChild)
    return () => observer.disconnect()
  }, [])

  const regionLabelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : captionedBy)
  const named = Boolean(regionLabelledBy || ariaLabel)

  return (
    <CaptionIdContext.Provider value={captionId}>
      <div
        ref={boxRef}
        data-slot="table-container"
        data-scrolling={scrolling || undefined}
        {...(scrolling
          ? {
              tabIndex: 0,
              ...(named
                ? {
                    role: sharesName ? "group" : "region",
                    "aria-labelledby": regionLabelledBy,
                    "aria-label": regionLabelledBy ? undefined : ariaLabel,
                  }
                : null),
            }
          : null)}
        className="relative w-full overflow-x-auto focus-ring-inset"
      >
        <table
          data-slot="table"
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          className={cn("w-full caption-bottom text-sm", className)}
          {...props}
        />
      </div>
    </CaptionIdContext.Provider>
  )
}

function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
  return (
    <thead
      data-slot="table-header"
      className={cn("[&_tr]:border-b [&_tr]:border-rule", 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(
        "border-t border-rule bg-muted/50 font-medium [&>tr]:last:border-b-0",
        className
      )}
      {...props}
    />
  )
}

// A selected row is the accent-muted plane, medium weight and a 2px accent
// rail. The rail is an inset shadow on the first cell: drawn as a ::before on
// the row, Chrome laid it out as one more cell and pushed every cell of a
// selected row a column to the right. A shadow has no logical offset, so a
// right-to-left row turns it round: the rail stays on the row's inline start.
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
  return (
    <tr
      data-slot="table-row"
      className={cn(
        "relative border-b transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-brand-muted data-[state=selected]:font-medium data-[state=selected]:*:first:shadow-[inset_2px_0_0_var(--brand)] rtl:data-[state=selected]:*:first:shadow-[inset_-2px_0_0_var(--brand)]",
        className
      )}
      {...props}
    />
  )
}

// Titles start at the inline start and a checkbox column trims its inline
// end, so a right-to-left table reads from the right like its rows do.
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
  return (
    <th
      data-slot="table-head"
      className={cn(
        "type-label h-10 px-2 text-start align-middle whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pe-0",
        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])]:pe-0",
        className
      )}
      {...props}
    />
  )
}

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

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