"use client"
import * as React from "react"
import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"
import { cn } from "@/registry/lib/utils"
/**
* Semantic column widths for "spec" tables (label · description · value), the
* recurring shape used across docs/reference tables. Declaring roles instead of
* raw rem values keeps every spec table proportioned consistently. These apply
* as MIN-widths (see TableHead): in the default auto layout each column holds its
* width as a floor but expands to fit long content, so a long label can never
* overflow into its neighbor. The description column (`width="fill"`) absorbs the
* remainder. Spec tables should use the default auto layout, not `layout="fixed"`.
*/
const COLUMN_WIDTHS = {
/** Leading label / name / token column. */
label: "10rem",
/** Trailing value / metric column (sizes, durations, token values). */
value: "8rem",
} as const
function isSpecWidth(w: string | undefined): w is keyof typeof COLUMN_WIDTHS {
return w === "label" || w === "value"
}
type TableDensity = "default" | "compact"
// Cell padding and body type live on TableHead/TableCell, so the root shares
// its density through context rather than fragile descendant selectors.
const TableDensityContext = React.createContext<TableDensity>("default")
// Whether the header row is frozen. Shared through context so a consumer flips
// it once on <Table> and TableHeader picks it up, without threading a prop
// through every header the consumer renders.
const TableStickyHeaderContext = React.createContext(false)
// Match an element against one of this module's components by a stable marker
// rather than referential identity. Identity (`node.type === Component`) breaks
// under Fast Refresh / HMR and across bundler boundaries, which silently zeroes
// out the column detection below. A string `displayName` survives both (and
// minification, since it is an explicit literal), so the detection stays correct.
function isElementOfType(
node: React.ReactNode,
component: { displayName?: string },
): node is React.ReactElement {
if (!React.isValidElement(node)) return false
if (node.type === component) return true
if (typeof node.type === "string") return false
return (node.type as { displayName?: string }).displayName === component.displayName
}
type TableProps = React.ComponentProps<"table"> & {
/**
* Column-sizing strategy.
*
* "auto" (default) - columns size to their content. Visual rhythm comes
* from consistent cell padding, not from width math.
* Best for most product tables. Matches what Linear,
* Stripe, Notion, GitHub, and shadcn/ui all do.
*
* "fixed" - columns honor their declared widths regardless of content.
* The table fills its container. Use when you need:
* predictable widths for streaming/virtualized data,
* user-resizable columns, sticky columns, or clean
* text-overflow ellipsis truncation.
*
* NOTE: in fixed mode, content wider than a column's
* width overflows into the next column unless you
* truncate it. For docs/reference "spec" tables with
* variable-length labels, prefer the default auto layout:
* the semantic `label`/`value` widths act as min-widths
* that expand to fit, so nothing ever overflows.
*/
layout?: "auto" | "fixed"
/**
* Width of the first column. Only applied when `layout="fixed"`.
* Pass null to disable. Defaults to 280px in fixed mode.
*/
firstColumnWidth?: string | null
/**
* Width of the last column. Only applied when `layout="fixed"`.
* Pass null to disable. Defaults to 160px in fixed mode.
*/
lastColumnWidth?: string | null
/** Override auto-detected column count when children aren't a simple Header > Row > Head tree. */
columnCount?: number
/** Apply alternating row background to body rows. */
striped?: boolean
/**
* Row density.
*
* "default" - reading-comfort tier: text-sm body, px-6 py-3 cells, h-11 header.
* "compact" - one type step down (text-xs body) with tightened cells
* (px-4 py-2, h-9 header). Use for dense reference and spec
* tables where scan speed matters more than reading comfort.
*/
density?: TableDensity
/**
* Freeze the header row so it stays visible while the body scrolls: the thead
* becomes `sticky top-0` at the sticky z-tier, carrying its opaque header
* background so rows pass beneath it. Needs a scrolling ancestor with a
* bounded height (a ScrollFade viewport, or an `overflow-y-auto` container
* with a height). Pair with ScrollFade `fade="bottom"` so a top skim does not
* fade the frozen header.
*/
stickyHeader?: boolean
/**
* The container surface.
*
* "card" (default) - the table paints its own card: rounded-lg, a real
* border, bg-card, shadow-xs. Correct when the table IS
* the surface on the screen.
*
* "bare" - no border, background, radius, or shadow. Use when the
* table already sits inside a Card or any other surface:
* two nested cards read as a bug (two edges, two radii,
* a shadow inside a shadow), and the fix belongs at the
* call site rather than in a `className` that has to
* unset four properties.
*
* Row rules, the header fill, and sticky-header behavior are identical in
* both: only the container chrome changes.
*/
surface?: "card" | "bare"
children?: React.ReactNode
}
function Table({
className,
layout = "auto",
firstColumnWidth = "280px",
lastColumnWidth = "160px",
columnCount: columnCountProp,
striped,
density = "default",
stickyHeader = false,
surface = "card",
children,
...props
}: TableProps) {
const isFixed = layout === "fixed"
// Read the first header row to learn the column count and each column's
// declared `width`, so a fixed table can build a colgroup that honors the
// per-column widths (semantic tokens, explicit values, or "fill"/"auto").
const { columnCount, columnWidths } = React.useMemo(() => {
if (columnCountProp !== undefined) {
return { columnCount: columnCountProp, columnWidths: [] as (string | undefined)[] }
}
const widths: (string | undefined)[] = []
let done = false
React.Children.forEach(children, (node) => {
if (done || !isElementOfType(node, TableHeader)) return
React.Children.forEach((node.props as { children?: React.ReactNode }).children, (rowNode) => {
if (done || !isElementOfType(rowNode, TableRow)) return
React.Children.forEach((rowNode.props as { children?: React.ReactNode }).children, (cell) => {
if (React.isValidElement(cell)) widths.push((cell.props as { width?: string }).width)
})
done = true
})
})
return { columnCount: widths.length, columnWidths: widths }
}, [children, columnCountProp])
// A column gets an explicit colgroup width when it's labelled (semantic token
// or raw value); "fill"/"auto"/undeclared columns are left to share the
// remaining space evenly (native table-fixed behavior). firstColumnWidth /
// lastColumnWidth remain the fallback for fixed tables that declare nothing.
const cols = isFixed && columnCount > 0 ? Array.from({ length: columnCount }, (_, i) => {
const declared = columnWidths[i]
const width =
declared === "fill" || declared === "auto" ? undefined :
isSpecWidth(declared) ? COLUMN_WIDTHS[declared] :
declared ? declared :
i === 0 && firstColumnWidth ? firstColumnWidth :
i === columnCount - 1 && lastColumnWidth ? lastColumnWidth :
undefined
return <col key={i} style={width ? { width } : undefined} />
}) : null
return (
<div
data-slot="table-container"
data-surface={surface}
className={cn(
"relative w-full",
// Container tier, not form-control tier: this paints its own card
// surface, so it rounds like Card (`rounded-lg`) rather than like an
// Input. It keeps a real border instead of Card's ring because the
// header and row rules are borders too, and they have to line up.
// `surface="bare"` drops all of it for a table that already sits on a
// surface, so nothing nests a card inside a card.
surface === "card" && "rounded-lg border border-border bg-card shadow-xs",
// A frozen header needs an OUTER scroll container to own scrolling: the
// built-in overflow-x-auto is itself a scroll container and would trap
// the sticky thead (it would stick to this wrapper, not the ancestor,
// and scroll away). Drop it when stickyHeader is set so the sticky
// header resolves against the bounded-height ancestor. A frozen-header
// table is expected to fit horizontally in that ancestor.
stickyHeader ? "overflow-visible" : "overflow-x-auto",
)}
>
<table
data-slot="table"
data-density={density}
className={cn(
"w-full caption-bottom",
density === "compact" ? "text-xs" : "text-sm",
isFixed && "table-fixed",
striped && "[&_tbody_tr:nth-child(even)]:bg-neutral-400/[0.04] dark:[&_tbody_tr:nth-child(even)]:bg-neutral-500/[0.04]",
className,
)}
{...props}
>
{cols && <colgroup>{cols}</colgroup>}
<TableDensityContext.Provider value={density}>
<TableStickyHeaderContext.Provider value={stickyHeader}>
{children}
</TableStickyHeaderContext.Provider>
</TableDensityContext.Provider>
</table>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
const sticky = React.useContext(TableStickyHeaderContext)
return (
<thead
data-slot="table-header"
className={cn(
"bg-muted",
sticky
// Frozen header: the opaque bg-muted travels with the sticky thead, so
// body rows scroll underneath it (z-20 is the sticky-header z-tier).
// The divider is an INSET box-shadow on the header CELLS, not a border
// and not an outset shadow. A collapsed border is shared with the
// first body row and scrolls away under the sticky header; an outset
// shadow paints below the cell, into the first row, where the body
// cells (later in the DOM) paint over it. An inset shadow draws the
// 1px line at the bottom edge INSIDE the header cell, so it can't be
// covered or scroll away.
// border-b-0 suppresses TableRow's own baked-in bottom border on the
// header row, so the inset shadow is the SOLE divider (otherwise the
// row border and the shadow stack into a double 1px line at rest).
? "sticky top-0 z-20 [&_tr]:border-b-0 [&_th]:shadow-[inset_0_-1px_0_0_var(--color-border)]"
: "[&_tr]:border-b [&_tr]:border-border",
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-border bg-muted font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b border-border",
"data-[state=selected]:bg-accent-100 dark:data-[state=selected]:bg-accent-700 [&[data-state=selected]_td]:text-accent dark:[&[data-state=selected]_td]:text-accent-200",
className
)}
{...props}
/>
)
}
type SortDirection = "asc" | "desc" | false
type Align = "left" | "center" | "right"
function alignToTextClass(align: Align) {
return align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left"
}
function alignToJustifyClass(align: Align) {
return align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start"
}
function SortGlyph({ sorted }: { sorted: SortDirection }) {
if (sorted === "asc") {
return <ChevronUp aria-hidden="true" className="size-3.5 shrink-0 text-foreground" />
}
if (sorted === "desc") {
return <ChevronDown aria-hidden="true" className="size-3.5 shrink-0 text-foreground" />
}
return <ChevronsUpDown aria-hidden="true" className="size-3.5 shrink-0 opacity-40" />
}
type TableHeadProps = React.ComponentProps<"th"> & {
sortable?: boolean
sorted?: SortDirection
onSort?: () => void
/**
* Column alignment. Defaults to "left", or "right" when `numeric` is set.
*
* Align by content type:
* - Text, names, descriptions, dates, status badges → "left"
* - Magnitudes (prices, counts, %) → "right" + tabular-nums (use `numeric`)
* - Identifier numbers (IDs, SKUs, ports) → "left" - they're labels, not values
* - Row actions → a dedicated trailing "Actions" column, right-aligned.
* Keep one frequent, labelled action visible and put every secondary
* state or destructive action in an always-available More menu.
* - Icon-only / boolean-only (single ✓, star, avatar) → "center"
*/
align?: Align
/** Right-align with tabular-nums. Use for magnitude columns. For identifier numbers (IDs/SKUs), leave default. */
numeric?: boolean
/**
* Column width strategy. For "spec" tables (label · description · value),
* prefer the semantic tokens with `layout="fixed"` so every such table is
* proportioned identically:
* "label" - leading label/name column (a shared min-width floor that expands to fit).
* "value" - trailing value/metric column (a shared min-width floor that expands to fit).
* "fill" - absorb the remaining horizontal space (the description column).
* In a fixed table, multiple fill/undeclared columns split it evenly.
* "auto" - shrink to content width. Pair with whitespace-nowrap (auto layout).
* string - explicit CSS width (e.g. "120px", "20%").
*/
width?: "label" | "value" | "fill" | "auto" | string
}
function TableHead({ className, sortable, sorted, onSort, align, numeric, width, style, children, ...props }: TableHeadProps) {
const resolvedAlign: Align = align ?? (numeric ? "right" : "left")
const density = React.useContext(TableDensityContext)
// Spec widths are min-widths, not hard widths: in a spec table (auto layout)
// the column holds this as a proportional floor but expands to fit long
// content instead of overflowing into its neighbor. In a true fixed table the
// colgroup carries the exact width, so this min is a harmless floor.
const widthStyle: React.CSSProperties | undefined =
width === "fill" ? { width: "100%" }
: width === "auto" ? { width: "1px" }
: isSpecWidth(width) ? { minWidth: COLUMN_WIDTHS[width] }
: width ? { width }
: undefined
return (
<th
data-slot="table-head"
scope="col"
style={{ ...widthStyle, ...style }}
className={cn(
"align-middle font-semibold text-2xs text-muted-foreground uppercase tracking-wide whitespace-nowrap select-none",
density === "compact" ? "h-9 px-4" : "h-11 px-6",
alignToTextClass(resolvedAlign),
numeric && "tabular-nums",
"[&:has([role=checkbox])]:pr-0",
sortable && "cursor-pointer hover:text-foreground transition-colors duration-150",
className
)}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : sortable ? "none" : undefined}
{...props}
>
{sortable ? (
<button
type="button"
onClick={onSort}
// Buttons inherit font/letter-spacing/color via preflight, but NOT
// text-transform - re-apply uppercase so sortable headers match plain ones.
className={cn(
"inline-flex items-center gap-1.5 w-full h-full cursor-pointer rounded-sm uppercase outline-none focus-visible:ring-ring-focus focus-visible:ring-[3px]",
alignToJustifyClass(resolvedAlign)
)}
>
{children}
<SortGlyph sorted={sorted ?? false} />
</button>
) : (
children
)}
</th>
)
}
type TableCellProps = React.ComponentProps<"td"> & {
align?: Align
numeric?: boolean
}
function TableCell({ className, align, numeric, ...props }: TableCellProps) {
const resolvedAlign: Align = align ?? (numeric ? "right" : "left")
const density = React.useContext(TableDensityContext)
return (
<td
data-slot="table-cell"
className={cn(
"align-middle text-muted-foreground",
density === "compact" ? "px-4 pt-4 pb-5" : "px-6 py-3",
alignToTextClass(resolvedAlign),
numeric && "tabular-nums",
"[&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-subtle-foreground py-2 px-3 text-xs text-left", className)}
{...props}
/>
)
}
// Stable markers so column detection (isElementOfType) survives HMR, bundler
// boundaries, and minification, where referential identity is not guaranteed.
Table.displayName = "Table"
TableHeader.displayName = "TableHeader"
TableBody.displayName = "TableBody"
TableFooter.displayName = "TableFooter"
TableRow.displayName = "TableRow"
TableHead.displayName = "TableHead"
TableCell.displayName = "TableCell"
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}