Data Display

Table

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.

Install

npx shadcn@latest add @paragon/table

table.tsx

"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>
  );
}