Skip to content

Data Table

A table for application data: you describe columns, it handles sorting, row selection, loading and empty states and pagination. It is built from Table, Checkbox, Skeleton and Pagination, keeps every piece of state controllable, and never fetches data itself.

Recent orders
ORD-1040Maya ChenPaidMay 28, 2026$19.99
ORD-1041Ali HaddadPendingMay 27, 2026$56.99
ORD-1042Zoé MartinPaidMay 26, 2026$93.99
ORD-1043Ben OkaforRefundedMay 25, 2026$130.99
ORD-1044Lina ParkPaidMay 24, 2026$167.99
ORD-1045Omar BenaliPendingMay 23, 2026$204.99

Installation

Add Data Table to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.

Terminal
npx ck-ui-cli@latest add data-table
Adds
DataTable.tsxCheckbox.tsxField.tsxLabel.tsxPagination.tsxSkeleton.tsxTable.tsxto your CK-UI folder (src/components/ck-ui by default)
Installs
@radix-ui/react-checkbox@radix-ui/react-label@radix-ui/react-slotreact-iconsif your project does not have them yet

First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.

Manual installationInstall the packages and copy the files yourself.

Install the following dependencies

Run these commands in your terminal to install the required packages

Terminal
npm install @radix-ui/react-slot react-icons @radix-ui/react-checkbox @radix-ui/react-label

Add the Table component

DataTable imports ./Table, so add this file next to it

/components/ui/Table.tsx
"use client";

// Client component: sorting and row selection are shared through React context (Table.Root `contextValue`).

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { RiArrowDownSFill, RiArrowUpSFill, RiExpandUpDownFill } from "react-icons/ri";

export type TableSortDirection = "asc" | "desc";

export interface TableContextValue {
    sorted?: { column: string; direction: TableSortDirection };
    onSort?: (column: string) => void;
    selected?: Set<string>;
    onSelect?: (id: string) => void;
}

const TableContext = React.createContext<TableContextValue>({});

export const useTable = () => React.useContext(TableContext);

const tableVariants = {
    root: {
        base: "w-full text-left",
    },
    wrapper: {
        base: "w-full overflow-auto",
    },
    header: {
        base: "w-full",
    },
    body: {
        base: "divide-y divide-border",
    },
    footer: {
        base: "bg-neutral-50 dark:bg-neutral-900",
    },
    row: {
        base: "py-2 group/row w-full",
        hover: "hover:bg-neutral-100 dark:hover:bg-neutral-800",
        selected: "bg-neutral-50/50 dark:bg-neutral-900/50",
    },
    headerCell: {
        base: "px-3 py-2 min-h-5 text-neutral-600 dark:text-neutral-400 bg-muted size-p-sm font-medium",
        first: "first:rounded-l-lg",
        last: "last:rounded-r-lg",
        sortable: "relative cursor-pointer select-none hover:bg-neutral-200 dark:hover:bg-neutral-800",
        sorted: "text-neutral-900 dark:text-neutral-100",
    },
    // The button fills the whole header cell (via ::after) so the click target matches the old clickable <th>.
    sortButton: "flex items-center gap-2 rounded-sm text-left focus-ring after:absolute after:inset-0",
    cell: {
        base: "h-12 px-3 transition duration-base ease-standard text-neutral-600 dark:text-neutral-400 size-p-sm",
        first: "first:rounded-l-xl",
        last: "last:rounded-r-xl",
        hover: "group-hover/row:bg-neutral-100 dark:group-hover/row:bg-neutral-800",
    },
    caption: {
        base: "mt-4 size-p-sm text-muted-foreground",
        top: "mt-0 mb-4",
    },
};

export interface TableRootProps extends React.HTMLAttributes<HTMLTableElement> {
    asChild?: boolean;
    /** Sorting and selection state shared with HeaderCell (`sortable`) and Row (`rowId`). */
    contextValue?: TableContextValue;
    /** Wrap the table in a horizontally scrollable container. */
    responsive?: boolean;
}

const Root = React.forwardRef<HTMLTableElement, TableRootProps>(({ className, asChild = false, contextValue, responsive = false, ...props }, ref) => {
    const Component = asChild ? Slot : "table";

    const table = <Component ref={ref} className={cn(tableVariants.root.base, className)} {...props} />;

    const wrappedTable = responsive ? <div className={tableVariants.wrapper.base}>{table}</div> : table;

    return contextValue ? <TableContext.Provider value={contextValue}>{wrappedTable}</TableContext.Provider> : wrappedTable;
});
Root.displayName = "Table.Root";

export interface TableCaptionProps extends React.HTMLAttributes<HTMLTableCaptionElement> {
    asChild?: boolean;
    position?: "top" | "bottom";
}

const Caption = React.forwardRef<HTMLTableCaptionElement, TableCaptionProps>(({ className, asChild = false, position = "bottom", ...props }, ref) => {
    const Component = asChild ? Slot : "caption";

    return <Component ref={ref} className={cn(tableVariants.caption.base, position === "top" && tableVariants.caption.top, className)} {...props} />;
});
Caption.displayName = "Table.Caption";

export interface TableHeaderProps extends React.HTMLAttributes<HTMLTableSectionElement> {
    asChild?: boolean;
}

const Header = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>(({ className, asChild = false, ...props }, ref) => {
    const Component = asChild ? Slot : "thead";

    return <Component ref={ref} className={cn(tableVariants.header.base, className)} {...props} />;
});
Header.displayName = "Table.Header";

export interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {
    asChild?: boolean;
}

const Body = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(({ className, asChild = false, ...props }, ref) => {
    const Component = asChild ? Slot : "tbody";

    return <Component ref={ref} className={cn(tableVariants.body.base, className)} {...props} />;
});
Body.displayName = "Table.Body";

export interface TableFooterProps extends React.HTMLAttributes<HTMLTableSectionElement> {
    asChild?: boolean;
}

const Footer = React.forwardRef<HTMLTableSectionElement, TableFooterProps>(({ className, asChild = false, ...props }, ref) => {
    const Component = asChild ? Slot : "tfoot";

    return <Component ref={ref} className={cn(tableVariants.footer.base, className)} {...props} />;
});
Footer.displayName = "Table.Footer";

export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
    asChild?: boolean;
    /** Header rows get no hover background. */
    isHeader?: boolean;
    /** Hover background on body rows. */
    hover?: boolean;
    /** Selected styling when the row is not driven by the table context. */
    selected?: boolean;
    /** With a `contextValue.selected` set, the row's selection is read from it and clicking the row calls `contextValue.onSelect(rowId)`. */
    rowId?: string;
}

const Row = React.forwardRef<HTMLTableRowElement, TableRowProps>(({ className, asChild = false, isHeader = false, hover = true, selected = false, rowId, onClick, ...props }, ref) => {
    const Component = asChild ? Slot : "tr";
    const { selected: selectedSet, onSelect } = useTable();

    const isSelected = rowId && selectedSet ? selectedSet.has(rowId) : selected;

    // Compose: the consumer's onClick runs first and can call event.preventDefault() to skip selection.
    const handleClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
        onClick?.(event);
        if (!event.defaultPrevented && rowId && onSelect) {
            onSelect(rowId);
        }
    };

    return (
        <Component
            ref={ref}
            className={cn(tableVariants.row.base, hover && !isHeader && tableVariants.row.hover, isSelected && tableVariants.row.selected, className)}
            data-state={isSelected ? "selected" : undefined}
            onClick={onClick || (rowId && onSelect) ? handleClick : undefined}
            {...props}
        />
    );
});
Row.displayName = "Table.Row";

export interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
    asChild?: boolean;
    /** Render the header content as a sort button that calls `contextValue.onSort(sortKey)`. Requires `sortKey`. */
    sortable?: boolean;
    sortKey?: string;
}

function SortIcon({ direction }: { direction?: TableSortDirection }) {
    if (direction === "asc") return <RiArrowUpSFill className="size-4 shrink-0 ml-1 text-primary-strong" aria-hidden="true" />;
    if (direction === "desc") return <RiArrowDownSFill className="size-4 shrink-0 ml-1 text-primary-strong" aria-hidden="true" />;
    return <RiExpandUpDownFill className="size-4 shrink-0 ml-1 text-neutral-400" aria-hidden="true" />;
}

const HeaderCell = React.forwardRef<HTMLTableCellElement, TableHeaderCellProps>(({ className, asChild = false, sortable = false, sortKey, scope = "col", children, ...props }, ref) => {
    const { sorted, onSort } = useTable();

    const isSortable = sortable && sortKey !== undefined;
    const sortDirection = isSortable && sorted?.column === sortKey ? sorted.direction : undefined;
    // Only the column the data is currently sorted by carries aria-sort (WAI-ARIA APG sortable table).
    const ariaSort = sortDirection === "asc" ? "ascending" : sortDirection === "desc" ? "descending" : undefined;

    const classes = cn(
        tableVariants.headerCell.base,
        tableVariants.headerCell.first,
        tableVariants.headerCell.last,
        isSortable && tableVariants.headerCell.sortable,
        sortDirection && tableVariants.headerCell.sorted,
        className
    );

    if (asChild) {
        // Slot is typed with generic HTML attributes, so the <th>-only attributes travel in the spread.
        const cellProps = { scope, ...props };
        return (
            <Slot ref={ref} aria-sort={ariaSort} className={classes} {...cellProps}>
                {children}
            </Slot>
        );
    }

    return (
        <th ref={ref} scope={scope} aria-sort={ariaSort} className={classes} {...props}>
            {isSortable ? (
                <button type="button" className={tableVariants.sortButton} onClick={() => onSort?.(sortKey)}>
                    {children}
                    <SortIcon direction={sortDirection} />
                </button>
            ) : (
                <div className="flex items-center gap-2">{children}</div>
            )}
        </th>
    );
});
HeaderCell.displayName = "Table.HeaderCell";

export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
    asChild?: boolean;
}

const Cell = React.forwardRef<HTMLTableCellElement, TableCellProps>(({ className, asChild = false, ...props }, ref) => {
    const Component = asChild ? Slot : "td";

    return <Component ref={ref} className={cn(tableVariants.cell.base, tableVariants.cell.first, tableVariants.cell.last, tableVariants.cell.hover, className)} {...props} />;
});
Cell.displayName = "Table.Cell";

export interface TableEmptyRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
    colSpan: number;
    children: React.ReactNode;
}

const EmptyRow = React.forwardRef<HTMLTableRowElement, TableEmptyRowProps>(({ colSpan, children, className, ...props }, ref) => {
    return (
        <tr ref={ref} className={className} {...props}>
            <td colSpan={colSpan}>
                <div className="flex flex-col items-center justify-center w-full p-4 text-center">
                    <svg
                        className="size-10 text-muted-foreground"
                        aria-hidden="true"
                        xmlns="http://www.w3.org/2000/svg"
                        width="24"
                        height="24"
                        viewBox="0 0 24 24"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                    >
                        <line x1="22" x2="2" y1="12" y2="12" />
                        <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
                        <line x1="6" x2="6.01" y1="16" y2="16" />
                        <line x1="10" x2="10.01" y1="16" y2="16" />
                    </svg>
                    <p className="mt-5 size-p-sm text-muted-foreground dark:text-neutral-300">{children}</p>
                </div>
            </td>
        </tr>
    );
});
EmptyRow.displayName = "Table.EmptyRow";

const Table = {
    Root,
    Caption,
    Header,
    Body,
    Footer,
    Row,
    HeaderCell,
    /** @deprecated Use `HeaderCell`. */
    Head: HeaderCell,
    Cell,
    EmptyRow,
};

export { Table, Root, Caption, Header, Body, Footer, Row, HeaderCell, HeaderCell as Head, Cell, EmptyRow, TableContext };
export default Table;

Add the Checkbox component

DataTable imports ./Checkbox, so add this file next to it

/components/ui/Checkbox.tsx
"use client";

import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { cn } from "@/lib/utils";
import { useFieldControlProps } from "./Field";

export type CheckboxRootProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
    /** Error state (aria-invalid). Also taken from Field.Root. */
    invalid?: boolean;
};

/*
 * The 20px control stacks its layers in a single grid cell: a 16px coloured plate, a raised surface over it while the
 * box is empty, and the two marks. Each layer is styled from the Radix root's data-state (and disabled/aria-invalid),
 * so the component keeps no state of its own. The check mark uses pathLength=1, so its draw-in needs no measured length.
 */
const layer = "col-start-1 row-start-1";

const Root = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root>, CheckboxRootProps>(({ className, invalid, ...props }, ref) => {
    const controlProps = useFieldControlProps(props, { invalid });

    return (
        <CheckboxPrimitive.Root
            ref={ref}
            className={cn("group/checkbox relative grid size-5 shrink-0 place-items-center rounded-md focus-ring disabled:cursor-not-allowed", className)}
            {...controlProps}
        >
            <span
                aria-hidden="true"
                data-slot="plate"
                className={cn(
                    layer,
                    "size-4 rounded-[4px] bg-input transition-colors duration-base ease-out",
                    "group-hover/checkbox:bg-muted-foreground/40",
                    "group-aria-[invalid=true]/checkbox:bg-destructive",
                    "group-data-[state=checked]/checkbox:bg-primary group-data-[state=indeterminate]/checkbox:bg-primary",
                    "group-hover/checkbox:group-data-[state=checked]/checkbox:bg-primary-hover group-hover/checkbox:group-data-[state=indeterminate]/checkbox:bg-primary-hover",
                    "group-disabled/checkbox:bg-muted group-disabled/checkbox:group-data-[state=checked]/checkbox:bg-muted group-disabled/checkbox:group-data-[state=indeterminate]/checkbox:bg-muted"
                )}
            />
            {/* The surface sits 1.5px inside the plate (concentric corners), lifted by the design system's regular shadow. */}
            <span
                aria-hidden="true"
                data-slot="surface"
                className={cn(
                    layer,
                    "size-[13px] rounded-[2.5px] bg-background shadow-regular transition-opacity duration-base ease-out",
                    "group-data-[state=checked]/checkbox:opacity-0 group-data-[state=indeterminate]/checkbox:opacity-0 group-disabled/checkbox:hidden"
                )}
            />
            <CheckboxPrimitive.Indicator forceMount className={cn(layer, "grid size-4 place-items-center")}>
                <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" data-slot="check" className={cn(layer, "size-4 opacity-0 group-data-[state=checked]/checkbox:opacity-100")}>
                    <path
                        d="M4.25 8 6.75 10.5 11.75 5.5"
                        pathLength={1}
                        strokeWidth={1.5}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        className={cn(
                            "stroke-primary-foreground dark:stroke-background",
                            "[stroke-dasharray:1] [stroke-dashoffset:1] group-data-[state=checked]/checkbox:[stroke-dashoffset:0]",
                            "transition-[stroke-dashoffset] duration-slow ease-out motion-reduce:transition-none"
                        )}
                    />
                </svg>
                <span
                    aria-hidden="true"
                    data-slot="dash"
                    className={cn(
                        layer,
                        "h-[1.5px] w-2 origin-left scale-x-0 rounded-full bg-primary-foreground dark:bg-background",
                        "transition-transform duration-slow ease-out motion-reduce:transition-none group-data-[state=indeterminate]/checkbox:scale-x-100"
                    )}
                />
            </CheckboxPrimitive.Indicator>
        </CheckboxPrimitive.Root>
    );
});
Root.displayName = "Checkbox.Root";

export { Root };

Add the Skeleton component

DataTable imports ./Skeleton, so add this file next to it

/components/ui/Skeleton.tsx
import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Placeholder shapes shown while content loads. Skeletons are decorative (aria-hidden): announce the
 * loading state on the container instead, e.g. aria-busy="true" plus visually hidden "Loading…" text.
 * Server-compatible. The pulse stops under prefers-reduced-motion.
 */

export type SkeletonShape = "text" | "circle" | "rect";

const shapeClasses: Record<SkeletonShape, string> = {
    text: "h-4 w-full rounded-md",
    circle: "size-10 shrink-0 rounded-full",
    rect: "h-32 w-full rounded-2xl",
};

// foreground/10 stays visible on background, surface and muted in both themes.
const base = "block animate-pulse bg-foreground/10 motion-reduce:animate-none";

export interface SkeletonRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Default size and rounding. Size anything with `className` (e.g. `h-6 w-24`). */
    shape?: SkeletonShape;
}

const Root = React.forwardRef<HTMLDivElement, SkeletonRootProps>(({ shape = "text", className, ...props }, ref) => (
    <div ref={ref} aria-hidden="true" data-shape={shape} className={cn(base, shapeClasses[shape], className)} {...props} />
));
Root.displayName = "Skeleton.Root";

export interface SkeletonTextProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Number of lines. The last one is shorter, like the end of a paragraph. */
    lines?: number;
    /** Classes for every line. */
    lineClassName?: string;
}

const Text = React.forwardRef<HTMLDivElement, SkeletonTextProps>(({ lines = 3, lineClassName, className, ...props }, ref) => {
    const count = Math.max(1, Math.floor(lines));

    return (
        <div ref={ref} aria-hidden="true" className={cn("flex w-full flex-col gap-2", className)} {...props}>
            {Array.from({ length: count }, (_, index) => (
                <div key={index} data-shape="text" className={cn(base, shapeClasses.text, count > 1 && index === count - 1 && "w-4/5", lineClassName)} />
            ))}
        </div>
    );
});
Text.displayName = "Skeleton.Text";

export { Root, Text };

Add the Pagination component

DataTable imports ./Pagination, so add this file next to it

/components/ui/Pagination.tsx
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { RiArrowLeftSLine, RiArrowRightSLine, RiMoreLine } from "react-icons/ri";
import { cn } from "@/lib/utils";

/*
 * Page navigation: <nav aria-label="Pagination"><ul>…</ul></nav>.
 * Pagination keeps no state. Each link is an <a> when given `href`, your own element with `asChild`
 * (e.g. Next.js Link), or a <button> otherwise (for state you manage with onClick).
 * getPaginationRange() computes which page numbers and ellipses to show. Server-compatible: no hooks, and no
 * event handlers are passed to your link elements unless you pass onClick yourself.
 */

export type PaginationSize = "sm" | "md";

const Root = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement>>(({ "aria-label": ariaLabel = "Pagination", className, ...props }, ref) => (
    <nav ref={ref} aria-label={ariaLabel} className={cn("flex w-full justify-center", className)} {...props} />
));
Root.displayName = "Pagination.Root";

const Content = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(({ className, ...props }, ref) => (
    <ul ref={ref} className={cn("flex flex-wrap items-center gap-0.5 sm:gap-1", className)} {...props} />
));
Content.displayName = "Pagination.Content";

const Item = React.forwardRef<HTMLLIElement, React.LiHTMLAttributes<HTMLLIElement>>(({ className, ...props }, ref) => (
    <li ref={ref} className={cn("flex", className)} {...props} />
));
Item.displayName = "Pagination.Item";

const sizeClasses: Record<PaginationSize, string> = {
    sm: "h-8 min-w-8 px-2",
    md: "h-9 min-w-9 px-2.5",
};

export interface PaginationLinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "type"> {
    /** Marks the current page (aria-current="page"). */
    isActive?: boolean;
    /** Not interactive: removed from the tab order and announced as unavailable. */
    disabled?: boolean;
    size?: PaginationSize;
    /** Render the single child (e.g. a Next.js <Link>) with the link styles and state. */
    asChild?: boolean;
}

const Link = React.forwardRef<HTMLElement, PaginationLinkProps>(
    ({ isActive = false, disabled = false, size = "md", asChild = false, href, className, children, onClick, "aria-label": ariaLabel, ...props }, ref) => {
        const classes = cn(
            "inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-lg border size-label-sm transition-colors duration-base ease-standard focus-ring [&>svg]:size-5 [&>svg]:shrink-0",
            sizeClasses[size],
            isActive ? "border-transparent bg-primary text-primary-foreground hover:bg-primary-hover" : "border-transparent text-foreground hover:bg-muted",
            "aria-disabled:pointer-events-none aria-disabled:text-muted-foreground disabled:pointer-events-none disabled:text-muted-foreground",
            className
        );
        // "3" alone is ambiguous out of context; "Page 3" keeps the visible text in the name.
        // With asChild the number is the child's own text (e.g. <Link href="?page=3">3</Link>).
        const text = asChild && React.isValidElement<{ children?: React.ReactNode }>(children) ? children.props.children : children;
        const label = ariaLabel ?? (typeof text === "number" || typeof text === "string" ? `Page ${text}` : undefined);
        const shared = {
            className: classes,
            "aria-label": label,
            "aria-current": isActive ? ("page" as const) : undefined,
            "data-active": isActive ? "" : undefined,
        };

        if (asChild) {
            // A disabled step must not be followable, so the consumer's link is replaced by an inert anchor (no href)
            // with the same content. No event handler is added, which keeps asChild usable from Server Components.
            if (disabled) {
                const child = React.isValidElement<{ children?: React.ReactNode; className?: string }>(children) ? children : null;
                return (
                    <a ref={ref as React.Ref<HTMLAnchorElement>} {...shared} className={cn(classes, child?.props.className)} role="link" aria-disabled="true" {...props}>
                        {child ? child.props.children : children}
                    </a>
                );
            }

            return (
                <Slot ref={ref} {...shared} onClick={onClick} {...props}>
                    {children}
                </Slot>
            );
        }

        if (href !== undefined) {
            // A disabled link loses its href, so it cannot be focused or followed.
            return (
                <a
                    ref={ref as React.Ref<HTMLAnchorElement>}
                    {...shared}
                    href={disabled ? undefined : href}
                    role={disabled ? "link" : undefined}
                    aria-disabled={disabled || undefined}
                    onClick={onClick}
                    {...props}
                >
                    {children}
                </a>
            );
        }

        return (
            <button
                ref={ref as React.Ref<HTMLButtonElement>}
                type="button"
                {...shared}
                disabled={disabled}
                onClick={onClick as unknown as React.MouseEventHandler<HTMLButtonElement>}
                {...(props as React.ButtonHTMLAttributes<HTMLButtonElement>)}
            >
                {children}
            </button>
        );
    }
);
Link.displayName = "Pagination.Link";

export interface PaginationStepProps extends Omit<PaginationLinkProps, "isActive"> {
    /** Visible text, shown from the `sm` breakpoint (icon only below it). */
    label?: string;
}

const Previous = React.forwardRef<HTMLElement, PaginationStepProps>(({ label = "Previous", "aria-label": ariaLabel = "Go to previous page", asChild, children, className, ...props }, ref) => {
    if (asChild) {
        return (
            <Link ref={ref} asChild aria-label={ariaLabel} className={cn("px-2 sm:pl-2 sm:pr-3", className)} {...props}>
                {children}
            </Link>
        );
    }

    return (
        <Link ref={ref} aria-label={ariaLabel} className={cn("px-2 sm:pl-2 sm:pr-3", className)} {...props}>
            <RiArrowLeftSLine aria-hidden="true" />
            <span className="hidden sm:inline">{children ?? label}</span>
        </Link>
    );
});
Previous.displayName = "Pagination.Previous";

const Next = React.forwardRef<HTMLElement, PaginationStepProps>(({ label = "Next", "aria-label": ariaLabel = "Go to next page", asChild, children, className, ...props }, ref) => {
    if (asChild) {
        return (
            <Link ref={ref} asChild aria-label={ariaLabel} className={cn("px-2 sm:pl-3 sm:pr-2", className)} {...props}>
                {children}
            </Link>
        );
    }

    return (
        <Link ref={ref} aria-label={ariaLabel} className={cn("px-2 sm:pl-3 sm:pr-2", className)} {...props}>
            <span className="hidden sm:inline">{children ?? label}</span>
            <RiArrowRightSLine aria-hidden="true" />
        </Link>
    );
});
Next.displayName = "Pagination.Next";

export interface PaginationEllipsisProps extends React.HTMLAttributes<HTMLSpanElement> {
    /** Text for screen readers (default "More pages"). */
    label?: string;
    size?: PaginationSize;
}

const Ellipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>(({ label = "More pages", size = "md", className, ...props }, ref) => (
    <span ref={ref} className={cn("flex items-center justify-center text-muted-foreground", size === "sm" ? "size-8" : "size-9", className)} {...props}>
        <RiMoreLine aria-hidden="true" className="size-4" />
        <span className="sr-only">{label}</span>
    </span>
));
Ellipsis.displayName = "Pagination.Ellipsis";

export type PaginationRangeItem = number | "ellipsis";

export interface PaginationRangeOptions {
    /** Current page (1-based). */
    page: number;
    pageCount: number;
    /** Pages shown on each side of the current page (default 1). */
    siblingCount?: number;
    /** Pages always shown at the start and end (default 1). */
    boundaryCount?: number;
}

const range = (start: number, end: number) => (end < start ? [] : Array.from({ length: end - start + 1 }, (_, index) => start + index));

/**
 * The page numbers to render, with "ellipsis" where pages are skipped.
 * getPaginationRange({ page: 5, pageCount: 10 }) → [1, "ellipsis", 4, 5, 6, "ellipsis", 10]
 */
export function getPaginationRange({ page, pageCount, siblingCount = 1, boundaryCount = 1 }: PaginationRangeOptions): PaginationRangeItem[] {
    const count = Math.max(0, Math.floor(pageCount));
    if (count === 0) return [];
    const current = Math.min(count, Math.max(1, Math.floor(page)));
    const siblings = Math.max(0, Math.floor(siblingCount));
    const boundary = Math.max(1, Math.floor(boundaryCount));

    const startPages = range(1, Math.min(boundary, count));
    const endPages = range(Math.max(count - boundary + 1, boundary + 1), count);

    const siblingsStart = Math.max(Math.min(current - siblings, count - boundary - siblings * 2 - 1), boundary + 2);
    const siblingsEnd = Math.min(Math.max(current + siblings, boundary + siblings * 2 + 2), endPages.length > 0 ? endPages[0] - 2 : count - 1);

    const items: PaginationRangeItem[] = [...startPages];

    if (siblingsStart > boundary + 2) items.push("ellipsis");
    else if (boundary + 1 < count - boundary) items.push(boundary + 1);

    items.push(...range(siblingsStart, siblingsEnd));

    if (siblingsEnd < count - boundary - 1) items.push("ellipsis");
    else if (count - boundary > boundary) items.push(count - boundary);

    items.push(...endPages);

    // Small page counts can produce duplicates at the joins; keep the first of each.
    return items.filter((item, index) => item === "ellipsis" || items.indexOf(item) === index);
}

export { Root, Content, Item, Link, Previous, Next, Ellipsis };

Add the Field component

DataTable imports ./Field, so add this file next to it

/components/ui/Field.tsx
"use client";

import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";

/*
 * Field wires a label, description and message to one form control:
 *   Field.Label        → <label for={controlId}>
 *   Field.Description  → referenced by aria-describedby
 *   Field.Message      → referenced by aria-describedby (error text when invalid)
 *   invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
 * Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
 * useFieldControlProps. It is plain React state: it works with native forms and form libraries.
 */

type FieldContextValue = {
    controlId: string;
    labelId: string;
    descriptionId: string;
    messageId: string;
    invalid: boolean;
    required: boolean;
    disabled: boolean;
    hasDescription: boolean;
    hasMessage: boolean;
    setHasDescription: (value: boolean) => void;
    setHasMessage: (value: boolean) => void;
};

const FieldContext = React.createContext<FieldContextValue | null>(null);

export function useField() {
    return React.useContext(FieldContext);
}

/**
 * Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
 * this lets each Field.Root reflect that state (label styling, `disabled` on the control).
 */
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);

export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
    /** Id for the control; generated when omitted. */
    id?: string;
    invalid?: boolean;
    required?: boolean;
    disabled?: boolean;
}

const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
    const fieldset = React.useContext(FieldsetContext);
    const disabled = disabledProp || Boolean(fieldset?.disabled);
    const generatedId = React.useId();
    const controlId = id ?? `field-${generatedId}`;
    const [hasDescription, setHasDescription] = React.useState(false);
    const [hasMessage, setHasMessage] = React.useState(false);

    const value = React.useMemo<FieldContextValue>(
        () => ({
            controlId,
            labelId: `${controlId}-label`,
            descriptionId: `${controlId}-description`,
            messageId: `${controlId}-message`,
            invalid,
            required,
            disabled,
            hasDescription,
            hasMessage,
            setHasDescription,
            setHasMessage,
        }),
        [controlId, invalid, required, disabled, hasDescription, hasMessage]
    );

    return (
        <FieldContext.Provider value={value}>
            <div
                ref={ref}
                className={cn("flex flex-col gap-1.5", className)}
                data-invalid={invalid || undefined}
                data-disabled={disabled || undefined}
                {...props}
            >
                {children}
            </div>
        </FieldContext.Provider>
    );
});
Root.displayName = "Field.Root";

type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;

const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
    const field = useField();

    return (
        <Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
            {children}
            {field?.required && <Label.Asterisk />}
        </Label.Root>
    );
});
FieldLabel.displayName = "Field.Label";

const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
    const field = useField();
    const setHasDescription = field?.setHasDescription;

    React.useEffect(() => {
        if (!setHasDescription) return;
        setHasDescription(true);
        return () => setHasDescription(false);
    }, [setHasDescription]);

    return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";

export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
    /** Only render while the field is invalid (default true). */
    showWhenValid?: boolean;
}

const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
    const field = useField();
    const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
    const setHasMessage = field?.setHasMessage;

    React.useEffect(() => {
        if (!setHasMessage || !visible) return;
        setHasMessage(true);
        return () => setHasMessage(false);
    }, [setHasMessage, visible]);

    if (!visible) return null;

    return (
        <p
            ref={ref}
            id={field?.messageId}
            aria-live="polite"
            className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
            {...props}
        >
            {children}
        </p>
    );
});
Message.displayName = "Field.Message";

type ControlAttributes = {
    id?: string;
    "aria-describedby"?: string;
    "aria-invalid"?: React.AriaAttributes["aria-invalid"];
    "aria-required"?: React.AriaAttributes["aria-required"];
    required?: boolean;
    disabled?: boolean;
};

/**
 * Merges Field context into a control's props. Explicit props win; describedby ids are combined.
 */
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
    const field = useField();
    const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");

    const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
        .filter(Boolean)
        .join(" ");

    return {
        ...props,
        id: props.id ?? field?.controlId,
        "aria-describedby": describedBy || undefined,
        "aria-invalid": invalid || undefined,
        required: props.required ?? (field?.required || undefined),
        disabled: props.disabled ?? (field?.disabled || undefined),
    };
}

/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
    const controlProps = useFieldControlProps(props);
    return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";

export { Root, FieldLabel as Label, Description, Message, Control };

Add the Label component

DataTable imports ./Label, so add this file next to it

/components/ui/Label.tsx
"use client";

import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";

export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
    /** Dims the label. The control itself must also be disabled. */
    disabled?: boolean;
}

const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
    return (
        <LabelPrimitive.Root
            ref={ref}
            data-disabled={disabled || undefined}
            className={cn(
                "group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
                "data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
                className
            )}
            {...props}
        />
    );
});
Root.displayName = "Label.Root";

// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
    <span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
        {children || "*"}
    </span>
));
Asterisk.displayName = "Label.Asterisk";

const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
    <span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";

export { Root, Asterisk, Sub };

Create or update your DataTable component

Copy this code to create your DataTable component

/components/ui/DataTable.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import * as Checkbox from "./Checkbox";
import * as Pagination from "./Pagination";
import * as Skeleton from "./Skeleton";
import * as Table from "./Table";

/*
 * A data table built on Table: column definitions, sorting (Table's aria-sort header buttons), optional row
 * selection (Checkbox), loading (Skeleton), an empty state and pagination (Pagination).
 * Every piece of state can be controlled, and `manualSorting` / `manualPagination` let you sort and page on the
 * server: DataTable then only renders what you give it. It never fetches data.
 * sortRows / paginateRows / compareValues are exported for use outside the component.
 */

export type DataTableSortDirection = "asc" | "desc";
export type DataTableSorting = { columnId: string; direction: DataTableSortDirection } | null;
export type DataTableCellValue = string | number | boolean | Date | null | undefined;

export interface DataTableColumn<TRow> {
    id: string;
    header: React.ReactNode;
    /** Value for sorting and the default cell text. */
    accessor?: (row: TRow) => DataTableCellValue;
    /** Cell content. Defaults to the accessor's value as text. */
    cell?: (row: TRow) => React.ReactNode;
    sortable?: boolean;
    /** Custom comparator for this column (ascending order). */
    sortFn?: (a: TRow, b: TRow) => number;
    align?: "start" | "center" | "end";
    headerClassName?: string;
    /** Cells do not wrap by default (the table scrolls); add "whitespace-normal" for long text. */
    cellClassName?: string;
}

const collator = typeof Intl !== "undefined" ? new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }) : null;

/** Ascending comparison: numbers and dates numerically, text with a numeric-aware collator, empty values last. */
export function compareValues(a: DataTableCellValue, b: DataTableCellValue): number {
    const aEmpty = a === null || a === undefined || a === "";
    const bEmpty = b === null || b === undefined || b === "";
    if (aEmpty || bEmpty) return aEmpty === bEmpty ? 0 : aEmpty ? 1 : -1;
    if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
    if (typeof a === "number" && typeof b === "number") return a - b;
    if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
    const textA = String(a);
    const textB = String(b);
    return collator ? collator.compare(textA, textB) : textA.localeCompare(textB);
}

/** Stable sort by one column. Returns a new array; the input is not modified. */
export function sortRows<TRow>(rows: readonly TRow[], columns: readonly DataTableColumn<TRow>[], sorting: DataTableSorting): TRow[] {
    if (!sorting) return [...rows];
    const column = columns.find((entry) => entry.id === sorting.columnId);
    if (!column || (!column.sortFn && !column.accessor)) return [...rows];
    const factor = sorting.direction === "asc" ? 1 : -1;
    const compare = column.sortFn ?? ((a: TRow, b: TRow) => compareValues(column.accessor!(a), column.accessor!(b)));
    return rows
        .map((row, index) => ({ row, index }))
        .sort((a, b) => {
            // Empty values stay last in both directions.
            if (!column.sortFn && column.accessor) {
                const aValue = column.accessor(a.row);
                const bValue = column.accessor(b.row);
                const aEmpty = aValue === null || aValue === undefined || aValue === "";
                const bEmpty = bValue === null || bValue === undefined || bValue === "";
                if (aEmpty !== bEmpty) return aEmpty ? 1 : -1;
            }
            return compare(a.row, b.row) * factor || a.index - b.index;
        })
        .map((entry) => entry.row);
}

/** The rows of one page (1-based). */
export function paginateRows<TRow>(rows: readonly TRow[], page: number, pageSize: number): TRow[] {
    const size = Math.max(1, Math.floor(pageSize));
    const start = (Math.max(1, Math.floor(page)) - 1) * size;
    return rows.slice(start, start + size);
}

function useControllable<T>(value: T | undefined, defaultValue: T, onChange: ((value: T) => void) | undefined) {
    const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
    const isControlled = value !== undefined;
    const setValue = React.useCallback(
        (next: T) => {
            if (!isControlled) setUncontrolled(next);
            onChange?.(next);
        },
        [isControlled, onChange]
    );
    return [isControlled ? (value as T) : uncontrolled, setValue] as const;
}

const alignClasses = { start: "text-left", center: "text-center", end: "text-right" } as const;

const formatCell = (value: DataTableCellValue) => (value instanceof Date ? value.toLocaleDateString() : value === null || value === undefined ? "" : String(value));

export interface DataTableRootProps<TRow> {
    data: readonly TRow[];
    columns: readonly DataTableColumn<TRow>[];
    getRowId: (row: TRow, index: number) => string;
    /** Names the table (rendered as <caption>). Visually hidden unless `captionVisible`. */
    caption: React.ReactNode;
    captionVisible?: boolean;

    sorting?: DataTableSorting;
    defaultSorting?: DataTableSorting;
    onSortingChange?: (sorting: DataTableSorting) => void;
    /** `data` is already sorted (e.g. by the server): only report sorting changes. */
    manualSorting?: boolean;

    /** Adds a checkbox column. */
    selectable?: boolean;
    selectedRowIds?: string[];
    defaultSelectedRowIds?: string[];
    onSelectedRowIdsChange?: (ids: string[]) => void;
    /** Text used in each row checkbox's label ("Select <label>"). Defaults to the row id. */
    getRowLabel?: (row: TRow) => string;

    /** Rows per page. Pagination is shown when set. */
    pageSize?: number;
    page?: number;
    defaultPage?: number;
    onPageChange?: (page: number) => void;
    /** `data` is already the current page: `rowCount` gives the total. */
    manualPagination?: boolean;
    rowCount?: number;
    paginationLabel?: string;

    loading?: boolean;
    /** Skeleton rows shown while loading. */
    loadingRows?: number;
    emptyMessage?: React.ReactNode;
    className?: string;
}

function Root<TRow>({
    data,
    columns,
    getRowId,
    caption,
    captionVisible = false,
    sorting: sortingProp,
    defaultSorting = null,
    onSortingChange,
    manualSorting = false,
    selectable = false,
    selectedRowIds,
    defaultSelectedRowIds = [],
    onSelectedRowIdsChange,
    getRowLabel,
    pageSize,
    page: pageProp,
    defaultPage = 1,
    onPageChange,
    manualPagination = false,
    rowCount,
    paginationLabel = "Table pagination",
    loading = false,
    loadingRows = 5,
    emptyMessage = "No results.",
    className,
}: DataTableRootProps<TRow>) {
    const captionId = `${React.useId()}-caption`;
    const [sorting, setSorting] = useControllable<DataTableSorting>(sortingProp, defaultSorting, onSortingChange);
    const [selected, setSelected] = useControllable<string[]>(selectedRowIds, defaultSelectedRowIds, onSelectedRowIdsChange);
    const [page, setPage] = useControllable<number>(pageProp, defaultPage, onPageChange);
    const [announcement, setAnnouncement] = React.useState("");

    const sortedRows = React.useMemo(() => (manualSorting ? [...data] : sortRows(data, columns, sorting)), [data, columns, sorting, manualSorting]);
    const total = manualPagination ? (rowCount ?? data.length) : sortedRows.length;
    const paginated = pageSize !== undefined;
    const pageCount = paginated ? Math.max(1, Math.ceil(total / Math.max(1, pageSize))) : 1;
    const currentPage = Math.min(Math.max(1, page), pageCount);
    const pageRows = paginated && !manualPagination ? paginateRows(sortedRows, currentPage, pageSize) : sortedRows;
    const indexByRow = React.useMemo(() => new Map(data.map((row, index) => [row, index] as const)), [data]);
    const rowIds = pageRows.map((row) => getRowId(row, indexByRow.get(row) ?? 0));
    const selectedSet = new Set(selected);
    const selectedOnPage = rowIds.filter((id) => selectedSet.has(id)).length;
    const columnCount = columns.length + (selectable ? 1 : 0);

    const headerLabel = (columnId: string) => {
        const header = columns.find((column) => column.id === columnId)?.header;
        return typeof header === "string" ? header : columnId;
    };

    const onSort = (columnId: string) => {
        // none → ascending → descending → none
        const next: DataTableSorting =
            sorting?.columnId !== columnId ? { columnId, direction: "asc" } : sorting.direction === "asc" ? { columnId, direction: "desc" } : null;
        setSorting(next);
        setAnnouncement(next ? `Sorted by ${headerLabel(next.columnId)}, ${next.direction === "asc" ? "ascending" : "descending"}` : "Sorting removed");
        if (paginated && currentPage !== 1) setPage(1);
    };

    const toggleRow = (id: string, checked: boolean) => setSelected(checked ? [...selected.filter((entry) => entry !== id), id] : selected.filter((entry) => entry !== id));

    const togglePage = (checked: boolean) => {
        const others = selected.filter((id) => !rowIds.includes(id));
        setSelected(checked ? [...others, ...rowIds] : others);
    };

    const paginationRef = React.useRef<HTMLElement>(null);
    // Set when a pagination control changes the page, so focus can be kept in the pagination afterwards.
    const restorePaginationFocus = React.useRef(false);
    const [paginationFocusRequest, setPaginationFocusRequest] = React.useState(0);

    const goTo = (next: number) => {
        if (paginationRef.current?.contains(document.activeElement)) {
            restorePaginationFocus.current = true;
            setPaginationFocusRequest((count) => count + 1);
        }
        setPage(Math.min(Math.max(1, next), pageCount));
    };

    // The control that changed the page can become disabled (Previous on page 1, Next on the last page, every
    // control while loading) or move within the page list, and the browser then drops focus to <body>.
    // Once the page has settled, keep focus in the pagination on the current page instead.
    React.useEffect(() => {
        if (!restorePaginationFocus.current || loading) return;
        restorePaginationFocus.current = false;
        const nav = paginationRef.current;
        const active = document.activeElement;
        const focusKept = active instanceof HTMLElement && nav?.contains(active) && !active.matches(":disabled, [aria-disabled='true']");
        if (!focusKept) nav?.querySelector<HTMLElement>('[aria-current="page"]')?.focus();
    }, [paginationFocusRequest, currentPage, loading]);

    const firstRow = total === 0 ? 0 : (currentPage - 1) * (pageSize ?? total) + 1;
    const lastRow = Math.min(total, currentPage * (pageSize ?? total));
    const footerStatus = [
        selectable && selected.length > 0 ? `${selected.length} selected` : null,
        paginated ? (total === 0 ? "0 rows" : `${firstRow}–${lastRow} of ${total}`) : null,
    ].filter((part): part is string => part !== null);

    return (
        <div className={cn("flex w-full min-w-0 flex-col gap-3", className)}>
            {/* Scroll container: focusable so keyboard users can scroll wide tables horizontally. `relative` keeps
                visually hidden (sr-only, absolutely positioned) cell content inside it instead of widening the page. */}
            <div role="region" aria-labelledby={captionId} tabIndex={0} className="relative w-full overflow-x-auto rounded-xl focus-ring">
                <Table.Root
                    aria-busy={loading || undefined}
                    contextValue={{
                        sorted: sorting ? { column: sorting.columnId, direction: sorting.direction } : undefined,
                        onSort,
                    }}
                    className="min-w-full"
                >
                    <Table.Caption id={captionId} position="top" className={cn(!captionVisible && "sr-only")}>
                        {caption}
                    </Table.Caption>
                    <Table.Header>
                        <Table.Row isHeader>
                            {selectable && (
                                <Table.HeaderCell className="w-10">
                                    <Checkbox.Root
                                        aria-label="Select all rows on this page"
                                        checked={rowIds.length > 0 && selectedOnPage === rowIds.length ? true : selectedOnPage > 0 ? "indeterminate" : false}
                                        onCheckedChange={(checked) => togglePage(checked === true)}
                                        disabled={loading || rowIds.length === 0}
                                    />
                                </Table.HeaderCell>
                            )}
                            {columns.map((column) => (
                                <Table.HeaderCell
                                    key={column.id}
                                    sortable={Boolean(column.sortable && (column.accessor || column.sortFn))}
                                    sortKey={column.id}
                                    className={cn("whitespace-nowrap", alignClasses[column.align ?? "start"], column.headerClassName)}
                                >
                                    {column.header}
                                </Table.HeaderCell>
                            ))}
                        </Table.Row>
                    </Table.Header>
                    <Table.Body>
                        {loading ? (
                            Array.from({ length: Math.max(1, loadingRows) }, (_, rowIndex) => (
                                <Table.Row key={`loading-${rowIndex}`} hover={false}>
                                    {Array.from({ length: columnCount }, (_, cellIndex) => (
                                        <Table.Cell key={cellIndex}>
                                            <Skeleton.Root className={cn("h-4", cellIndex === 0 && selectable ? "size-5 rounded-md" : "w-3/4")} />
                                        </Table.Cell>
                                    ))}
                                </Table.Row>
                            ))
                        ) : pageRows.length === 0 ? (
                            <Table.EmptyRow colSpan={columnCount}>{emptyMessage}</Table.EmptyRow>
                        ) : (
                            pageRows.map((row, index) => {
                                const id = rowIds[index];
                                const isSelected = selectedSet.has(id);
                                return (
                                    <Table.Row key={id} selected={isSelected} data-row-id={id}>
                                        {selectable && (
                                            <Table.Cell className="w-10">
                                                <Checkbox.Root
                                                    aria-label={`Select ${getRowLabel ? getRowLabel(row) : id}`}
                                                    checked={isSelected}
                                                    onCheckedChange={(checked) => toggleRow(id, checked === true)}
                                                />
                                            </Table.Cell>
                                        )}
                                        {/* No wrapping by default: the table scrolls horizontally instead of crushing columns. */}
                                        {columns.map((column) => (
                                            <Table.Cell key={column.id} className={cn("whitespace-nowrap", alignClasses[column.align ?? "start"], column.cellClassName)}>
                                                {column.cell ? column.cell(row) : formatCell(column.accessor?.(row))}
                                            </Table.Cell>
                                        ))}
                                    </Table.Row>
                                );
                            })
                        )}
                    </Table.Body>
                </Table.Root>
            </div>

            {/* Announces loading and sort changes without moving focus. */}
            <p role="status" className="sr-only">
                {loading ? "Loading rows" : announcement}
            </p>

            {footerStatus.length > 0 && (
                // In a narrow container the pagination wraps under the status and stays aligned to the end.
                <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
                    <p className="size-p-sm text-muted-foreground">
                        {footerStatus.map((part, index) => (
                            <React.Fragment key={index}>
                                {index > 0 && " · "}
                                <span className="whitespace-nowrap">{part}</span>
                            </React.Fragment>
                        ))}
                    </p>
                    {paginated && pageCount > 1 && (
                        <Pagination.Root ref={paginationRef} aria-label={paginationLabel} className="ml-auto w-auto">
                            <Pagination.Content>
                                <Pagination.Item>
                                    <Pagination.Previous size="sm" disabled={currentPage === 1 || loading} onClick={() => goTo(currentPage - 1)} />
                                </Pagination.Item>
                                {Pagination.getPaginationRange({ page: currentPage, pageCount, siblingCount: 0 }).map((item, index) => (
                                    <Pagination.Item key={item === "ellipsis" ? `ellipsis-${index}` : item}>
                                        {item === "ellipsis" ? (
                                            <Pagination.Ellipsis size="sm" />
                                        ) : (
                                            <Pagination.Link size="sm" isActive={item === currentPage} disabled={loading} onClick={() => goTo(item)}>
                                                {item}
                                            </Pagination.Link>
                                        )}
                                    </Pagination.Item>
                                ))}
                                <Pagination.Item>
                                    <Pagination.Next size="sm" disabled={currentPage === pageCount || loading} onClick={() => goTo(currentPage + 1)} />
                                </Pagination.Item>
                            </Pagination.Content>
                        </Pagination.Root>
                    )}
                </div>
            )}
        </div>
    );
}
Root.displayName = "DataTable.Root";

export { Root };

Test Your Component

  • 1. Add the component to a test page
  • 2. Check all props and variants work as expected
  • 3. Verify responsive behavior
  • 4. Test with different content/data
  • 5. Ensure accessibility features work correctly

Row Selection

selectable adds labelled checkboxes and a select-all checkbox for the current page. Control the selection to drive bulk actions.

Orders to review
Email
Maya Chenmaya@example.com$19.99
Ali Haddadali@example.com$56.99
Zoé Martinzoé@example.com$93.99
Ben Okaforben@example.com$130.99
Lina Parklina@example.com$167.99

Pagination

pageSize turns on pagination with the Pagination component and a row count. Sorting returns to page 1.

All orders
ORD-1040Maya ChenMay 28, 2026$19.99
ORD-1041Ali HaddadMay 27, 2026$56.99
ORD-1042Zoé MartinMay 26, 2026$93.99
ORD-1043Ben OkaforMay 25, 2026$130.99
ORD-1044Lina ParkMay 24, 2026$167.99

1–5 of 12

Loading and Empty

loading shows skeleton rows and marks the table busy; an empty data array shows emptyMessage across all columns.

Customers
Customer
Email
Status

Loading rows

Server-side Data

With manualSorting and manualPagination the table shows exactly the rows you pass and reports sorting and page changes; you fetch the matching page. rowCount is the total.

Orders (server-side)

Loading rows

0 rows

Accessibility

  • A real <table> named by its caption (visually hidden unless captionVisible), with column headers.
  • Sortable headers contain a button; the sorted column has aria-sort, and each change is announced ("Sorted by Customer, ascending").
  • Selection checkboxes are labelled per row (getRowLabel) plus "Select all rows on this page", with a mixed state.
  • While loading, the table has aria-busy and "Loading rows" is announced; skeletons are hidden from assistive tech.
  • Wide tables scroll inside a focusable, labelled region, so keyboard users can scroll them; the page itself never scrolls sideways.

API Reference

Import with import * as DataTable from "@/components/ui/DataTable". Columns hold functions (cell, accessor), so render DataTable from a client component.

DataTable.Root

PropTypeDefaultDescription
data"TRow[]""-"

Rows to show.

columns"DataTableColumn<TRow>[]""-"

Column definitions (below).

getRowId"(row, index) => string""-"

Stable id per row.

caption / captionVisible"ReactNode / boolean""- / false"

Table name.

sorting / defaultSorting / onSortingChange"{ columnId, direction } | null""null"

Sort state.

manualSorting"boolean""false"

Data is already sorted.

selectable"boolean""false"

Adds the checkbox column.

selectedRowIds / defaultSelectedRowIds / onSelectedRowIdsChange"string[]""[]"

Selection.

getRowLabel"(row) => string""row id"

Used in checkbox labels.

pageSize"number""undefined"

Enables pagination.

page / defaultPage / onPageChange"number""1"

Current page (1-based).

manualPagination / rowCount"boolean / number""false"

Data is the current page; total rows.

paginationLabel"string""Table pagination"

Name of the pagination landmark.

loading / loadingRows"boolean / number""false / 5"

Skeleton rows.

emptyMessage"ReactNode""No results."

Shown with no rows.

className"string""undefined"

Additional classes, merged last.

DataTableColumn<TRow>

PropTypeDefaultDescription
id"string""-"

Unique column id.

header"ReactNode""-"

Header content.

accessor"(row) => value""undefined"

Value for sorting and default text.

cell"(row) => ReactNode""accessor text"

Custom cell content.

sortable / sortFn"boolean / (a, b) => number""false"

Enable sorting; custom comparator.

align
"start""center""end"
"start"

Text alignment (end for numbers).

headerClassName / cellClassName"string""undefined"

Classes per column.

sortRows / paginateRows / compareValues

The pure helpers DataTable uses, exported for server code or your own tables.

PropTypeDefaultDescription
sortRows"(rows, columns, sorting) => rows""-"

Stable sort; empty values last.

paginateRows"(rows, page, pageSize) => rows""-"

One page (1-based).

compareValues"(a, b) => number""-"

Numbers, dates, numeric-aware text.

Used in these blocks

Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.