WUWensity UI

Table

Data tables with sorting, selection and three densities.

Installation

npx shadcn@latest add https://layro.dev/r/wensity/table.json

Or, with @layro in your components.json:

npx shadcn@latest add @layro/wensity/table

Code

"use client";

import * as React from "react";
import {
  IconChevronDown,
  IconChevronUp,
  IconSelector,
} from "@tabler/icons-react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "framer-motion";
import { cn } from "@/lib/utils";

const tableSurfaceClass = "rounded-[var(--primitive-radius-surface)]";

export type TableDensity = "compact" | "comfortable" | "spacious";
export type SortDirection = "asc" | "desc" | null;

export interface TableProps extends React.HTMLAttributes<HTMLDivElement> {
  stickyHeader?: boolean;
  density?: TableDensity;
}

export interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {
  /** Animate row reordering when sort order changes. */
  animatedSort?: boolean;
}

export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
  selected?: boolean;
  expanded?: boolean;
  /** Click anywhere on the row to toggle selection. */
  selectable?: boolean;
  onSelectedChange?: (selected: boolean) => void;
  /** Click anywhere on the row to toggle expansion. */
  expandable?: boolean;
  onExpandedChange?: (expanded: boolean) => void;
}

export interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
  sortable?: boolean;
  sortDirection?: SortDirection;
  onSort?: () => void;
}

export type TableCellProps = React.TdHTMLAttributes<HTMLTableCellElement>;

export interface TableExpandableContentProps
  extends React.HTMLAttributes<HTMLTableRowElement> {
  open?: boolean;
  colSpan: number;
}

const EASE_OUT = "cubic-bezier(0.16, 1, 0.3, 1)";
const EASE_MORPH = [0.32, 0.72, 0, 1] as const;
const SORT_LAYOUT_MS = 0.36;

// E-10 density scale: compact 11px/xs, comfortable xs/sm, spacious sm/sm.
const densityClasses: Record<TableDensity, { head: string; cell: string }> = {
  compact: { head: "h-9 px-3 text-[11px]", cell: "h-10 px-3 text-xs" },
  comfortable: { head: "h-11 px-4 text-xs", cell: "h-12 px-4 text-sm" },
  spacious: { head: "h-12 px-5 text-sm", cell: "h-14 px-5 text-sm" },
};

type TableContextValue = {
  density: TableDensity;
  stickyHeader: boolean;
  animatedSort: boolean;
  layoutGroupId: string;
};

const TableContext = React.createContext<TableContextValue>({
  density: "comfortable",
  stickyHeader: false,
  animatedSort: false,
  layoutGroupId: "wensity-table",
});

function useTableContext() {
  return React.useContext(TableContext);
}

function isInteractiveTarget(target: EventTarget | null) {
  if (!(target instanceof HTMLElement)) return false;
  return Boolean(
    target.closest(
      'button, a, input, label, textarea, select, [role="checkbox"], [data-slot="table-select-cell"], [data-prevent-row-action="true"]',
    ),
  );
}

function SortIndicator({ direction }: { direction: SortDirection }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <span className="relative inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false}>
        {direction === "asc" ? (
          <motion.span
            key="asc"
            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92, y: 3 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={shouldReduceMotion ? undefined : { opacity: 0, scale: 0.92, y: -3 }}
            transition={{ duration: SORT_LAYOUT_MS, ease: EASE_MORPH }}
            className="absolute inset-0 inline-flex items-center justify-center text-[var(--foreground)]"
          >
            <IconChevronUp size={14} stroke={2} />
          </motion.span>
        ) : direction === "desc" ? (
          <motion.span
            key="desc"
            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.92, y: -3 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={shouldReduceMotion ? undefined : { opacity: 0, scale: 0.92, y: 3 }}
            transition={{ duration: SORT_LAYOUT_MS, ease: EASE_MORPH }}
            className="absolute inset-0 inline-flex items-center justify-center text-[var(--foreground)]"
          >
            <IconChevronDown size={14} stroke={2} />
          </motion.span>
        ) : (
          <motion.span
            key="idle"
            initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.94 }}
            animate={{ opacity: 0.55, scale: 1 }}
            exit={shouldReduceMotion ? undefined : { opacity: 0, scale: 0.94 }}
            transition={{ duration: SORT_LAYOUT_MS * 0.85, ease: EASE_MORPH }}
            className="absolute inset-0 inline-flex items-center justify-center text-[var(--muted-foreground)]"
          >
            <IconSelector size={14} stroke={1.75} />
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

/* ─── Glide hover ───────────────────────────────────────────── */

/**
 * One hover highlight per list. It glides (fast motion tier) to the item
 * nearest the pointer, stays lit across the gaps between items, fades in where
 * the pointer enters instead of sliding over from its last spot, and a click
 * in a gap goes to the lit item. Items opt in with `data-glide-item`.
 */
type GlideRect = {
  x: number;
  y: number;
  w: number;
  h: number;
  session: number;
  visible: boolean;
};

const GLIDE_SPRING = { type: "spring", duration: 0.08, bounce: 0 } as const;

function useGlideHover<T extends HTMLElement = HTMLElement>(
  axis: "x" | "y" | "xy",
  {
    gapClick = true,
    itemSelector = "[data-glide-item]",
  }: { gapClick?: boolean; itemSelector?: string } = {},
) {
  const containerRef = React.useRef<T | null>(null);
  const activeRef = React.useRef<HTMLElement | null>(null);
  const sessionRef = React.useRef(0);
  const [glide, setGlide] = React.useState<GlideRect | null>(null);

  const pick = React.useCallback(
    (clientX: number, clientY: number) => {
      const root = containerRef.current;
      if (!root) return;
      let best: HTMLElement | null = null;
      let bestDistance = Infinity;
      for (const item of root.querySelectorAll<HTMLElement>(itemSelector)) {
        if (item.matches(':disabled, [data-disabled], [aria-disabled="true"]')) continue;
        const r = item.getBoundingClientRect();
        if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
          best = item;
          break;
        }
        const dx = axis === "y" ? 0 : clientX - (r.left + r.width / 2);
        const dy = axis === "x" ? 0 : clientY - (r.top + r.height / 2);
        const distance = dx * dx + dy * dy;
        if (distance < bestDistance) {
          bestDistance = distance;
          best = item;
        }
      }
      activeRef.current = best;
      if (!best) return;
      const rootRect = root.getBoundingClientRect();
      const r = best.getBoundingClientRect();
      const x = r.left - rootRect.left - root.clientLeft + root.scrollLeft;
      const y = r.top - rootRect.top - root.clientTop + root.scrollTop;
      const session = sessionRef.current;
      setGlide((prev) =>
        prev &&
        prev.visible &&
        prev.session === session &&
        prev.x === x &&
        prev.y === y &&
        prev.w === r.width &&
        prev.h === r.height
          ? prev
          : { x, y, w: r.width, h: r.height, session, visible: true },
      );
    },
    [axis, itemSelector],
  );

  const handlers = {
    onPointerEnter(event: React.PointerEvent) {
      if (event.pointerType !== "mouse") return;
      sessionRef.current += 1;
      pick(event.clientX, event.clientY);
    },
    onPointerMove(event: React.PointerEvent) {
      if (event.pointerType === "mouse") pick(event.clientX, event.clientY);
    },
    onPointerLeave() {
      activeRef.current = null;
      setGlide((prev) => (prev ? { ...prev, visible: false } : prev));
    },
    onClick(event: React.MouseEvent) {
      if (!gapClick) return;
      // Membership, not closest(): a scoped selector (":scope > li > …") only
      // matches from the container, so test each item for the click target.
      const root = containerRef.current;
      const target = event.target as Node;
      if (root && Array.from(root.querySelectorAll(itemSelector)).some((item) => item.contains(target))) return;
      activeRef.current?.click();
    },
  };

  return { containerRef, glide, handlers };
}

function GlideHighlight({
  glide,
  className,
}: {
  glide: GlideRect | null;
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  if (!glide) return null;
  const box = { x: glide.x, y: glide.y, width: glide.w, height: glide.h };
  return (
    <motion.span
      key={glide.session}
      aria-hidden="true"
      data-slot="glide-highlight"
      className={cn(
        "pointer-events-none absolute left-0 top-0 z-0 bg-[var(--primitive-surface-hover)]",
        className,
      )}
      initial={{ ...box, opacity: 0 }}
      animate={{ ...box, opacity: glide.visible ? 1 : 0 }}
      transition={
        reduceMotion
          ? { duration: 0, opacity: { duration: 0.08 } }
          : { ...GLIDE_SPRING, opacity: { duration: glide.visible ? 0.08 : 0.06 } }
      }
    />
  );
}

/**
 * One callback ref for the glide container and the caller's ref, so the caller
 * always gets the mounted element (also on branches without the glide).
 */
function assignGlideRef<T>(ref: React.Ref<T> | undefined, node: T | null) {
  if (typeof ref === "function") ref(node);
  else if (ref) (ref as React.RefObject<T | null>).current = node;
}

function useGlideRef<T extends HTMLElement>(
  forwarded: React.ForwardedRef<T>,
  containerRef: React.RefObject<T | null>,
) {
  return React.useCallback(
    (node: T | null) => {
      assignGlideRef(containerRef, node);
      assignGlideRef(forwarded, node);
    },
    [forwarded, containerRef],
  );
}

export const Table = React.forwardRef<HTMLDivElement, TableProps>(
  ({ className, stickyHeader = false, density = "comfortable", children, ...props }, ref) => {
    const layoutGroupId = React.useId();
    // Body rows share one hover highlight; header rows and gap clicks stay out.
    const glide = useGlideHover<HTMLDivElement>("y", {
      gapClick: false,
      itemSelector: 'tbody > tr[data-slot="table-row"]',
    });
    const tableRef = useGlideRef(ref, glide.containerRef);

    return (
      <TableContext.Provider
        value={{
          density,
          stickyHeader,
          animatedSort: false,
          layoutGroupId,
        }}
      >
        <div data-surface="panel" data-wensity-primitive=""
          ref={tableRef}
          data-slot="table"
          data-sticky-header={stickyHeader ? "true" : undefined}
          className={cn(
            "relative w-full overflow-auto",
            tableSurfaceClass,
            className,
          )}
          {...props}
          onPointerEnter={(event) => {
            props.onPointerEnter?.(event);
            glide.handlers.onPointerEnter(event);
          }}
          onPointerMove={(event) => {
            props.onPointerMove?.(event);
            glide.handlers.onPointerMove(event);
          }}
          onPointerLeave={(event) => {
            props.onPointerLeave?.(event);
            glide.handlers.onPointerLeave();
          }}
        >
          <GlideHighlight glide={glide.glide} />
          {/* relative: the table paints above the hover highlight behind it. */}
          <table className="relative w-full caption-bottom border-collapse text-left">{children}</table>
        </div>
      </TableContext.Provider>
    );
  },
);
Table.displayName = "Table";

export const TableCaption = React.forwardRef<
  HTMLTableCaptionElement,
  React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
  <caption
    ref={ref}
    className={cn("mt-3 px-4 text-xs text-[var(--muted-foreground)]", className)}
    {...props}
  />
));
TableCaption.displayName = "TableCaption";

export const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
  ({ className, ...props }, ref) => {
    const { stickyHeader } = useTableContext();
    return (
      <thead
        ref={ref}
        className={cn(
          "border-b border-[var(--border)] text-[var(--primitive-text-secondary,var(--muted-foreground))]",
          stickyHeader &&
            "sticky top-0 z-10 bg-[var(--muted)]/95 backdrop-blur-sm",
          className,
        )}
        {...props}
      />
    );
  },
);
TableHeader.displayName = "TableHeader";

export const TableBody = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(
  ({ className, animatedSort = false, children, ...props }, ref) => {
    const parentContext = useTableContext();
    const sortEnabled = animatedSort || parentContext.animatedSort;

    return (
      <TableContext.Provider
        value={{
          ...parentContext,
          animatedSort: sortEnabled,
        }}
      >
        <tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props}>
          {sortEnabled ? (
            <LayoutGroup id={parentContext.layoutGroupId}>{children}</LayoutGroup>
          ) : (
            children
          )}
        </tbody>
      </TableContext.Provider>
    );
  },
);
TableBody.displayName = "TableBody";

export const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
  ({ className, ...props }, ref) => (
    <tfoot
      ref={ref}
      className={cn(
        "border-t border-[color-mix(in_srgb,var(--foreground)_10%,transparent)] bg-[color-mix(in_srgb,var(--foreground)_4%,var(--background))] font-medium",
        className,
      )}
      {...props}
    />
  ),
);
TableFooter.displayName = "TableFooter";

export const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
  (
    {
      className,
      selected = false,
      expanded = false,
      selectable = false,
      onSelectedChange,
      expandable = false,
      onExpandedChange,
      onClick,
      children,
      style,
      ...props
    },
    ref,
  ) => {
    const { animatedSort } = useTableContext();
    const shouldReduceMotion = useReducedMotion();
    const isInteractive = selectable || expandable;

    const handleClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
      onClick?.(event);
      if (event.defaultPrevented || isInteractiveTarget(event.target)) return;

      if (selectable) {
        onSelectedChange?.(!selected);
      } else if (expandable) {
        onExpandedChange?.(!expanded);
      }
    };

    const sharedClass = cn(
      "border-b border-[var(--border)]",
      "transition-[background-color,box-shadow] duration-[var(--primitive-motion-fast)] ease-[var(--table-ease-out)] motion-reduce:transition-none",
      isInteractive && "cursor-pointer",
      selected &&
        "bg-[var(--primitive-surface-selected)] hover:bg-[var(--primitive-surface-selected)]",
      expanded &&
        !selected &&
        "bg-[var(--primitive-surface-hover)] hover:bg-[var(--primitive-surface-hover)]",
      className,
    );

    const sharedProps = {
      "data-slot": "table-row",
      "data-selected": selected ? "true" : undefined,
      "data-expanded": expanded ? "true" : undefined,
      "data-selectable": selectable ? "true" : undefined,
      "data-expandable": expandable ? "true" : undefined,
      onClick: isInteractive ? handleClick : onClick,
      className: sharedClass,
      style: { ["--table-ease-out" as string]: EASE_OUT, ...style },
      ...props,
    };

    if (animatedSort && !shouldReduceMotion) {
      const motionRowProps = sharedProps as React.ComponentPropsWithoutRef<typeof motion.tr>;

      return (
        <motion.tr
          ref={ref}
          {...motionRowProps}
          layout="position"
          transition={{
            layout: { duration: SORT_LAYOUT_MS, ease: EASE_MORPH },
          }}
        >
          {children}
        </motion.tr>
      );
    }

    return (
      <tr ref={ref} {...sharedProps}>
        {children}
      </tr>
    );
  },
);
TableRow.displayName = "TableRow";

export const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
  (
    {
      className,
      sortable = false,
      sortDirection = null,
      onSort,
      children,
      "aria-sort": ariaSort,
      ...props
    },
    ref,
  ) => {
    const { density } = useTableContext();
    const isActive = Boolean(sortDirection);
    const resolvedAriaSort =
      sortDirection === "asc"
        ? "ascending"
        : sortDirection === "desc"
          ? "descending"
          : ariaSort;

    const content = (
      <>
        <span className="truncate">{children}</span>
        {sortable ? <SortIndicator direction={sortDirection} /> : null}
      </>
    );

    const sharedClass = cn(
      "relative whitespace-nowrap font-medium tracking-[-0.01em] text-[var(--primitive-text-secondary,var(--muted-foreground))]",
      densityClasses[density].head,
      sortable && "cursor-pointer select-none",
      sortable &&
        "transition-[color,background-color] duration-[var(--primitive-motion-fast)] ease-[var(--table-ease-out)] hover:text-[var(--foreground)] motion-reduce:transition-none",
      isActive &&
        "text-[var(--foreground)] after:absolute after:inset-x-3 after:bottom-0 after:h-px after:origin-left after:scale-x-100 after:bg-[color-mix(in_srgb,var(--foreground)_22%,transparent)] after:transition-transform after:duration-[var(--primitive-motion-moderate)] after:ease-[var(--table-ease-out)]",
      className,
    );

    if (sortable) {
      return (
        <th
          ref={ref}
          scope="col"
          data-sort-active={isActive ? "true" : undefined}
          aria-sort={resolvedAriaSort}
          className={sharedClass}
          style={{ ["--table-ease-out" as string]: EASE_OUT }}
          {...props}
        >
          <button
            type="button"
            onClick={onSort}
            className="inline-flex w-full items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)]"
          >
            {content}
          </button>
        </th>
      );
    }

    return (
      <th
        ref={ref}
        scope="col"
        aria-sort={ariaSort}
        className={cn(sharedClass, "align-middle")}
        style={{ ["--table-ease-out" as string]: EASE_OUT }}
        {...props}
      >
        <span className="inline-flex items-center gap-1.5">{content}</span>
      </th>
    );
  },
);
TableHead.displayName = "TableHead";

export const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
  ({ className, ...props }, ref) => {
    const { density, animatedSort } = useTableContext();
    const shouldReduceMotion = useReducedMotion();

    const cellClass = cn(
      "align-middle text-[var(--foreground)]",
      densityClasses[density].cell,
      animatedSort &&
        !shouldReduceMotion &&
        "transition-[opacity,transform,filter] duration-[var(--primitive-motion-fast)] ease-[var(--table-ease-out)] motion-reduce:transition-none",
      className,
    );

    return (
      <td
        ref={ref}
        className={cellClass}
        style={{ ["--table-ease-out" as string]: EASE_OUT }}
        {...props}
      />
    );
  },
);
TableCell.displayName = "TableCell";

export interface TableSelectCellProps extends Omit<TableCellProps, "children"> {
  checked?: boolean;
  indeterminate?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  ariaLabel?: string;
}

function TableCheckbox({
  checked,
  indeterminate,
  onCheckedChange,
  ariaLabel,
}: {
  checked: boolean;
  indeterminate: boolean;
  onCheckedChange?: (checked: boolean) => void;
  ariaLabel: string;
}) {
  const inputRef = React.useRef<HTMLInputElement | null>(null);

  React.useEffect(() => {
    if (inputRef.current) inputRef.current.indeterminate = indeterminate;
  }, [indeterminate]);

  return (
    <label
      data-slot="table-checkbox"
      className={cn(
        "relative inline-grid size-5 cursor-pointer place-items-center rounded-[var(--primitive-radius-control-sm)] border border-[var(--border)]",
        "bg-[var(--primitive-field-face)] text-[var(--background)]",
        "transition-[background-color,border-color,color,box-shadow] duration-[var(--primitive-motion-fast)] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
        "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[var(--primitive-ring)] has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-[var(--background)]",
        (checked || indeterminate) &&
          "border-[var(--primitive-control-solid)] bg-[var(--primitive-control-solid)] text-[var(--primitive-control-solid-foreground)]",
      )}
    >
      <input
        ref={inputRef}
        type="checkbox"
        checked={checked}
        aria-label={ariaLabel}
        onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
        className="sr-only"
      />
      <svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5">
        {indeterminate ? (
          <path
            d="M4 8h8"
            fill="none"
            stroke="currentColor"
            strokeLinecap="round"
            strokeWidth="2"
          />
        ) : (
          <path
            d="m3.6 8.2 2.8 2.8 6-6"
            fill="none"
            stroke="currentColor"
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth="2"
          />
        )}
      </svg>
    </label>
  );
}

export const TableSelectCell = React.forwardRef<HTMLTableCellElement, TableSelectCellProps>(
  (
    {
      checked = false,
      indeterminate = false,
      onCheckedChange,
      ariaLabel = "Select row",
      className,
      onClick,
      ...props
    },
    ref,
  ) => {
    const { density } = useTableContext();

    return (
      <td
        ref={ref}
        data-slot="table-select-cell"
        className={cn(
          "w-10",
          densityClasses[density].cell,
          "px-3 text-center",
          className,
        )}
        onClick={(event) => {
          event.stopPropagation();
          onClick?.(event);
        }}
        {...props}
      >
        <TableCheckbox
          checked={checked}
          indeterminate={indeterminate}
          onCheckedChange={(value) => onCheckedChange?.(value === true)}
          ariaLabel={ariaLabel}
        />
      </td>
    );
  },
);
TableSelectCell.displayName = "TableSelectCell";

export interface TableExpandTriggerCellProps extends TableCellProps {
  expanded?: boolean;
  onToggle?: () => void;
  label?: string;
}

export const TableExpandTriggerCell = React.forwardRef<
  HTMLTableCellElement,
  TableExpandTriggerCellProps
>(({ expanded = false, onToggle, label = "Toggle row details", className, children, onClick, ...props }, ref) => {
  const { density } = useTableContext();

  return (
    <td
      ref={ref}
      data-prevent-row-action="true"
      className={cn(
        "w-10",
        densityClasses[density].cell,
        "px-3 text-center",
        className,
      )}
      onClick={(event) => {
        event.stopPropagation();
        onClick?.(event);
      }}
      {...props}
    >
      <button
        type="button"
        aria-expanded={expanded}
        aria-label={label}
        data-prevent-row-action="true"
        onClick={(event) => {
          event.stopPropagation();
          onToggle?.();
        }}
        className={cn(
          "inline-flex size-7 items-center justify-center rounded-[var(--primitive-radius-control-sm)]",
          "text-[var(--muted-foreground)] transition-[color,transform] duration-[var(--primitive-motion-fast)] ease-[var(--table-ease-out)] motion-reduce:transition-none",
          "hover:bg-[var(--primitive-surface-hover)] hover:text-[var(--foreground)]",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primitive-ring)]",
        )}
        style={{ ["--table-ease-out" as string]: EASE_OUT }}
      >
        <IconChevronDown
          size={16}
          stroke={2}
          className={cn(
            "transition-transform duration-[var(--primitive-motion-moderate)] ease-[var(--table-ease-out)] motion-reduce:transition-none",
            expanded && "rotate-180",
          )}
        />
      </button>
      {children}
    </td>
  );
});
TableExpandTriggerCell.displayName = "TableExpandTriggerCell";

export const TableExpandableContent = React.forwardRef<
  HTMLTableRowElement,
  TableExpandableContentProps
>(({ className, open = false, colSpan, children, ...props }, ref) => {
  return (
    <tr
      ref={ref}
      data-slot="table-expandable-content"
      aria-hidden={!open}
      className={cn("border-b border-[color-mix(in_srgb,var(--foreground)_8%,transparent)]", className)}
      {...props}
    >
      <td colSpan={colSpan} className="p-0">
        <div
          className={cn(
            "grid transition-[grid-template-rows,opacity] duration-[var(--primitive-motion-moderate)] ease-[var(--table-ease-out)] motion-reduce:transition-none",
            open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
          )}
          style={{ ["--table-ease-out" as string]: EASE_OUT }}
          inert={open ? undefined : true}
        >
          <div className="overflow-hidden">
            <div className="px-4 py-3 text-sm text-[var(--muted-foreground)]">{children}</div>
          </div>
        </div>
      </td>
    </tr>
  );
});
TableExpandableContent.displayName = "TableExpandableContent";

Versions

  1. 1.0.0 · current

    Oct 27, 2026

    First release.