A data table with a sticky header, hairline row borders, sortable columns whose arrow blur-swaps direction, right-aligned numeric columns, density modes, and checkbox row selection.
npx shadcn@latest add @paragon/table"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { ArrowDown, ArrowUp, Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
type TableDensity = "comfortable" | "compact";
const TableDensityContext = React.createContext<TableDensity>("comfortable");
interface TableRevealValue {
/** Rows have entered (or never animate). */
shown: boolean;
/** Reveal with no rise/blur — opacity only. */
reduced: boolean;
}
const TableRevealContext = React.createContext<TableRevealValue>({
shown: true,
reduced: true,
});
// Body rows read their document order to stagger the first-in-view reveal.
const TableRowIndexContext = React.createContext(0);
// Only rows inside <TableBody> take the reveal stagger; header rows don't.
const TableInBodyContext = React.createContext(false);
const headDensity: Record<TableDensity, string> = {
comfortable: "h-10 px-4",
compact: "h-8 px-3",
};
const cellDensity: Record<TableDensity, string> = {
comfortable: "px-4 py-3",
compact: "px-3 py-1.5",
};
export interface TableProps extends React.ComponentProps<"table"> {
/** Row rhythm. Compact tightens vertical padding for dense screens. */
density?: TableDensity;
/** Zebra-striped rows for scannability on wide tables. */
striped?: boolean;
/** Styles the scroll container (e.g. `max-h-80` to activate the sticky header). */
containerClassName?: string;
/** Renders rows in place with no first-in-view reveal. */
static?: boolean;
}
const TableStripedContext = React.createContext(false);
/**
* A clean data table on a card surface. The header sticks to the top of
* the scroll container; rows separate with hairline borders (dividers are
* borders, never shadows) and tint quietly on hover. On first scroll into
* view the body rows blur-rise with a short stagger — once, never on
* re-scroll. Uses `border-separate` so the sticky header keeps its bottom
* hairline.
*/
export function Table({
density = "comfortable",
striped = false,
containerClassName,
static: isStatic = false,
className,
...props
}: TableProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "0px 0px -32px 0px" });
const reducedMotion = useReducedMotion() ?? false;
const reveal = React.useMemo<TableRevealValue>(
() => ({
shown: isStatic || inView,
reduced: reducedMotion || isStatic,
}),
[isStatic, inView, reducedMotion],
);
return (
<TableDensityContext.Provider value={density}>
<TableStripedContext.Provider value={striped}>
<TableRevealContext.Provider value={reveal}>
<div
ref={ref}
data-slot="table-container"
className={cn(
"relative w-full overflow-auto rounded-xl bg-card shadow-border",
containerClassName,
)}
>
<table
data-slot="table"
className={cn(
"w-full caption-bottom border-separate border-spacing-0 text-sm",
className,
)}
{...props}
/>
</div>
</TableRevealContext.Provider>
</TableStripedContext.Provider>
</TableDensityContext.Provider>
);
}
export function TableHeader({
className,
...props
}: React.ComponentProps<"thead">) {
return <thead data-slot="table-header" className={className} {...props} />;
}
export function TableBody({
className,
children,
...props
}: React.ComponentProps<"tbody">) {
// Provide each direct child row its document index for stagger, without
// demanding the consumer thread an index through by hand.
const rows = React.Children.toArray(children);
return (
<tbody
data-slot="table-body"
className={cn("[&>tr:last-child>td]:border-b-0", className)}
{...props}
>
<TableInBodyContext.Provider value={true}>
{rows.map((child, i) => (
<TableRowIndexContext.Provider key={i} value={i}>
{child}
</TableRowIndexContext.Provider>
))}
</TableInBodyContext.Provider>
</tbody>
);
}
export interface TableRowProps extends React.ComponentProps<"tr"> {
/** Marks the row as selected (also sets `data-state="selected"`). */
selected?: boolean;
}
export function TableRow({ selected = false, className, ...props }: TableRowProps) {
const striped = React.useContext(TableStripedContext);
const inBody = React.useContext(TableInBodyContext);
const index = React.useContext(TableRowIndexContext);
const { shown, reduced } = React.useContext(TableRevealContext);
// Transforms are well-supported on table-row; blur is not, so rows reveal
// with opacity + a small rise only. Enumerate every animated property in
// one declaration so tailwind-merge doesn't drop the color transition.
const revealState = !inBody
? ""
: reduced
? shown
? "opacity-100"
: "opacity-0"
: shown
? "translate-y-0 opacity-100"
: "translate-y-1.5 opacity-0";
return (
<tr
data-slot="table-row"
data-state={selected ? "selected" : undefined}
style={
inBody && shown ? { transitionDelay: `${Math.min(index, 12) * 40}ms` } : undefined
}
className={cn(
"transition-[background-color,color,opacity,transform] duration-(--duration-base) ease-(--ease-out)",
striped && inBody && "[&:nth-child(even)>td]:bg-muted/25",
selected ? "bg-muted/60" : "hover:bg-muted/40",
revealState,
className,
)}
{...props}
/>
);
}
type SortDirection = "asc" | "desc" | null;
export interface TableHeadProps extends React.ComponentProps<"th"> {
/** Right-aligns the column for numbers. */
numeric?: boolean;
/** Current sort state for this column. */
sortDirection?: SortDirection;
/** Makes the header sortable; called on click. */
onSort?: () => void;
}
export function TableHead({
numeric = false,
sortDirection = null,
onSort,
className,
children,
...props
}: TableHeadProps) {
const density = React.useContext(TableDensityContext);
const reduced = useReducedMotion();
const sortable = onSort !== undefined;
const arrow = (
<span className="flex size-3.5 items-center justify-center" aria-hidden>
{sortDirection === "desc" ? (
<ArrowDown className="size-3" />
) : (
<ArrowUp
className={cn(
"size-3",
!sortDirection &&
"opacity-0 transition-opacity duration-(--duration-fast) group-hover:opacity-50 group-focus-visible:opacity-50",
)}
/>
)}
</span>
);
return (
<th
data-slot="table-head"
aria-sort={
sortDirection === "asc"
? "ascending"
: sortDirection === "desc"
? "descending"
: undefined
}
className={cn(
"sticky top-0 z-10 border-b bg-card text-left align-middle text-xs font-medium whitespace-nowrap text-muted-foreground",
headDensity[density],
numeric && "text-right",
className,
)}
{...props}
>
{sortable ? (
<button
type="button"
onClick={onSort}
className={cn(
"group -mx-1 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 align-middle transition-colors duration-(--duration-fast) select-none hover:text-foreground",
numeric && "flex-row-reverse",
)}
>
{children}
{reduced ? (
arrow
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={sortDirection ?? "none"}
className="flex"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{arrow}
</motion.span>
</AnimatePresence>
)}
</button>
) : (
children
)}
</th>
);
}
export interface TableCellProps extends React.ComponentProps<"td"> {
/** Right-aligns and sets tabular figures for numbers. */
numeric?: boolean;
}
export function TableCell({ numeric = false, className, ...props }: TableCellProps) {
const density = React.useContext(TableDensityContext);
return (
<td
data-slot="table-cell"
className={cn(
"border-b align-middle whitespace-nowrap",
cellDensity[density],
numeric && "text-right tabular-nums",
className,
)}
{...props}
/>
);
}
export function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("py-3 text-xs text-muted-foreground", className)}
{...props}
/>
);
}
export interface TableCheckboxProps
extends React.ComponentProps<typeof CheckboxPrimitive.Root> {}
/**
* The selection checkbox for the leading column. Row selection is a
* high-frequency action, so state changes are near-instant color moves —
* no mount theatrics. Supports `checked="indeterminate"` for the
* header select-all. Always pass an `aria-label`.
*/
export function TableCheckbox({ className, ...props }: TableCheckboxProps) {
return (
<CheckboxPrimitive.Root
data-slot="table-checkbox"
className={cn(
"relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-card align-middle transition-[background-color,border-color] duration-(--duration-fast) ease-(--ease-out)",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
"disabled:pointer-events-none disabled:opacity-50",
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
{props.checked === "indeterminate" ? (
<Minus className="size-3" strokeWidth={2.5} />
) : (
<Check className="size-3" strokeWidth={2.5} />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}