An adapter can also be set once for the whole app instead of on every DataTable: <SlotsmithProvider components={{ dataTable: shadcnComponents }}>. See how the provider's slots resolve.

shadcn/ui

This adapter renders DataTable with shadcn/ui parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports these shadcn/ui components; add them with npx shadcn@latest add button checkbox empty input pagination select skeleton table. Or install it in one step from slotsmith's shadcn/ui registry: add "@slotsmith": "https://slotsmith.dev/r/{name}.json" to the registries in your components.json, then run npx shadcn@latest add @slotsmith/data-table. It writes components/slotsmith/data-table.tsx, with these parts and a <DataTable /> that already renders with them, and adds the shadcn/ui components it needs. npx shadcn@latest add @slotsmith/provider applies every adapter to the whole app through one SlotsmithProvider.

The demo sits inside shadcn/ui's provider, as any app built on it already does.

shadcn.tsx

/**
 * DataTable for shadcn/ui
 *
 * A `components` map that renders the slotsmith data table with shadcn/ui
 * primitives, written for shadcn/ui on Tailwind CSS v4. Copy the file, keep
 * the parts you want, and pass the map as `components={shadcnComponents}`;
 * every slot left out keeps its fallback.
 *
 * The `@/components/ui/*` and `@/lib/utils` imports are the app's own
 * shadcn/ui files.
 */
import { useId, type ComponentProps } from "react";
import {
  fallbackComponents,
  type CellEditorSlotProps,
  type CellSlotProps,
  type CheckboxSlotProps,
  type DataTableComponents,
  type DragHandleSlotProps,
  type EmptySlotProps,
  type ErrorSlotProps,
  type FillHandleSlotProps,
  type PaginationSlotProps,
  type RowSlotProps,
} from "slotsmith/data-table";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Empty, EmptyContent, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Input } from "@/components/ui/input";
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";

/**
 * Shadcn checkbox adapter
 *
 * Maps the slot's `indeterminate` flag onto radix's tri-state `checked`, and
 * stops the click from reaching `onRowClick`, so ticking a box never opens
 * the row.
 */
const ShadcnCheckbox = ({ checked, indeterminate, onCheckedChange, ...props }: CheckboxSlotProps) => (
  <Checkbox
    checked={indeterminate ? "indeterminate" : checked}
    onCheckedChange={(value) => onCheckedChange(value === true)}
    onClick={(event) => event.stopPropagation()}
    {...props}
  />
);

/**
 * Shadcn cell
 *
 * `TableCell` with the spreadsheet styles: in spreadsheet mode a body cell
 * carries its part in the selection as `data-*` attributes. The active cell
 * and the one being edited get a ring in the primary colour, the selected
 * range a primary tint, the cells a fill drag would write and the copied
 * ones the muted colour with a thin ring. Read-only cells are muted, and
 * cells cannot be selected as text, except in the editor.
 */
const ShadcnCell = ({ className, ...props }: CellSlotProps) => (
  <TableCell
    className={cn(
      "data-column-id:cursor-cell data-column-id:select-none data-column-id:outline-none data-column-id:scroll-mt-12 [&_input]:select-text",
      "data-selected:bg-primary/10 data-fill-preview:bg-muted data-copied:bg-muted",
      "data-fill-preview:shadow-[inset_0_0_0_1px_var(--ring)] data-copied:shadow-[inset_0_0_0_1px_var(--ring)]",
      "data-active:shadow-[inset_0_0_0_2px_var(--primary)] data-editing:shadow-[inset_0_0_0_2px_var(--primary)] data-editing:bg-background",
      "aria-readonly:text-muted-foreground",
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn cell editor
 *
 * Text and numbers are edited in an `Input` with its border and ring taken
 * off, so the cell itself is the field, with the validation message on a
 * destructive chip above the cell. Selects and dates keep the built-in
 * editor, the slotsmith `Autocomplete` and `DatePicker`, which wear the
 * shadcn adapters given to `SlotsmithProvider`.
 */
function ShadcnCellEditor(props: CellEditorSlotProps) {
  const errorId = useId();
  if (props.kind !== "text" && props.kind !== "number") return <fallbackComponents.CellEditor {...props} />;
  const { onValueChange, error } = props;
  return (
    <div className="flex min-w-0 flex-1 items-center px-2">
      <Input
        className="h-auto rounded-none border-0 bg-transparent p-0 text-[length:inherit] shadow-none outline-none focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent"
        value={props.value}
        onChange={(event) => onValueChange(event.target.value)}
        inputMode={props.kind === "number" ? "decimal" : undefined}
        autoComplete="off"
        spellCheck={false}
        aria-label={props["aria-label"]}
        aria-invalid={error !== null || undefined}
        aria-describedby={error !== null ? errorId : undefined}
      />
      {error !== null && (
        <span
          id={errorId}
          role="alert"
          className="absolute start-0 bottom-full z-30 mb-0.5 rounded-sm bg-destructive px-2 text-xs whitespace-nowrap text-white"
        >
          {error}
        </span>
      )}
    </div>
  );
}

/**
 * Shadcn fill handle
 *
 * A small square in the primary colour inside the selection's corner, so it never overflows the scroll area.
 */
const ShadcnFillHandle = ({ className, ...props }: FillHandleSlotProps) => (
  <span
    className={cn(
      "absolute end-0 bottom-0 z-20 size-1.75 cursor-crosshair touch-none border border-background bg-primary",
      className,
    )}
    {...props}
  />
);

/**
 * Shadcn row
 *
 * `TableRow` with the drag styles for row reordering. While a row is lifted
 * (`data-dragging`), it and its visible sub-rows (`data-dragging-child`) are
 * drawn above the rows they pass, with opaque cells so those rows never show
 * through, and one shadow under the block. With reduced motion nothing
 * slides: the block is dimmed and a line on `data-drop-edge` shows where it
 * will land.
 */
const ShadcnRow = ({ className, ...props }: RowSlotProps) => (
  <TableRow
    className={cn(
      "data-dragging:relative data-dragging:z-10 data-dragging:shadow-lg data-dragging:*:bg-muted",
      "data-dragging-child:relative data-dragging-child:z-10 data-dragging-child:*:bg-muted",
      "[&[data-dragging]:has(+[data-dragging-child])]:shadow-none [&[data-dragging-child]:not(:has(+[data-dragging-child]))]:shadow-lg",
      "motion-reduce:shadow-none! motion-reduce:data-dragging:*:opacity-50 motion-reduce:data-dragging-child:*:opacity-50",
      "motion-reduce:data-[drop-edge=before]:*:shadow-[inset_0_2px_0_0_var(--primary)] motion-reduce:data-[drop-edge=after]:*:shadow-[inset_0_-2px_0_0_var(--primary)]",
      // Rows dragged in from another table of a reorder group: a line where they would land.
      "data-drop-incoming:data-[drop-edge=before]:*:shadow-[inset_0_2px_0_0_var(--primary)] data-drop-incoming:data-[drop-edge=after]:*:shadow-[inset_0_-2px_0_0_var(--primary)]",
      className,
    )}
    {...props}
  />
);

/**
 * Grip icon
 *
 * lucide's `GripVertical`, drawn inline with the same six dots and the same
 * `lucide lucide-grip-vertical` classes, so the file needs no icon package.
 * A project with `lucide-react` can import `GripVertical` from it instead.
 */
const GripVertical = ({ className, ...props }: ComponentProps<"svg">) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="24"
    height="24"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    className={cn("lucide lucide-grip-vertical", className)}
    {...props}
  >
    <circle cx="9" cy="12" r="1" />
    <circle cx="9" cy="5" r="1" />
    <circle cx="9" cy="19" r="1" />
    <circle cx="15" cy="12" r="1" />
    <circle cx="15" cy="5" r="1" />
    <circle cx="15" cy="19" r="1" />
  </svg>
);

/**
 * Shadcn drag handle
 *
 * A ghost icon `Button` with lucide's `GripVertical`. Every slot prop goes
 * to its `<button>`: the ref, the pointer and key handlers, the `aria-*`
 * attributes and `disabled`. shadcn's `Button` sets no `type`, so inside a
 * form it would submit it; `type="button"` comes first, so a slot prop can
 * still override it.
 */
const ShadcnDragHandle = ({ className, ...props }: DragHandleSlotProps) => (
  <Button
    type="button"
    variant="ghost"
    size="icon"
    className={cn("size-7 cursor-grab touch-none text-muted-foreground data-dragging:cursor-grabbing", className)}
    {...props}
  >
    <GripVertical />
  </Button>
);

/**
 * Visible pages
 *
 * A compact page list: every page up to 4, otherwise the first, the last,
 * and the neighbours of the current page with ellipses between.
 *
 * @param page - The current page, 1-based.
 * @param total - The page count.
 * @returns Page numbers and `"ellipsis"` markers.
 */
function visiblePages(page: number, total: number): (number | "ellipsis")[] {
  if (total <= 4) return Array.from({ length: total }, (_, i) => i + 1);
  const pages = [...new Set([1, page - 1, page, page + 1, total])].filter((p) => p >= 1 && p <= total);
  return pages.flatMap((p, i) => (i > 0 && p - pages[i - 1]! > 1 ? ["ellipsis" as const, p] : [p]));
}

/**
 * Chevron icons
 *
 * lucide's `ChevronLeft` and `ChevronRight`, drawn inline like the grip, and
 * turned round on right-to-left pages so they point the reading way.
 */
const Chevron = ({ d, className, ...props }: ComponentProps<"svg"> & { d: string }) => (
  <svg
    xmlns="http://www.w3.org/2000/svg"
    width="24"
    height="24"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth="2"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    className={cn("rtl:rotate-180", className)}
    {...props}
  >
    <path d={d} />
  </svg>
);
const ChevronLeft = (props: ComponentProps<"svg">) => <Chevron d="m15 18-6-6 6-6" {...props} className={cn("lucide lucide-chevron-left", props.className)} />;
const ChevronRight = (props: ComponentProps<"svg">) => <Chevron d="m9 18 6-6-6-6" {...props} className={cn("lucide lucide-chevron-right", props.className)} />;

/**
 * Shadcn pagination
 *
 * A compact pagination bar: the page summary, shadcn's `Select` for rows per
 * page, and numbered page `Button`s (the current one in the default variant
 * with `aria-current="page"`, the rest ghost) whose outline previous / next
 * buttons disappear at either end. Every word on it comes from `labels`, so
 * a locale or a custom label reaches it.
 */
function ShadcnPagination({ pageIndex, pageCount, pageSize, pageSizeOptions, setPageIndex, setPageSize, labels }: PaginationSlotProps) {
  const page = pageIndex + 1;
  const rowsPerPageId = useId();

  return (
    <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-6 px-6 py-4">
      <div className="text-sm text-muted-foreground">{labels.pageInfo(page, pageCount)}</div>
      <div className="flex items-center gap-2">
        <span id={rowsPerPageId} className="text-sm font-medium">
          {labels.rowsPerPage}
        </span>
        <Select value={String(pageSize)} onValueChange={(value) => setPageSize(Number(value))}>
          <SelectTrigger size="sm" aria-labelledby={rowsPerPageId} className="w-20">
            <SelectValue />
          </SelectTrigger>
          <SelectContent side="top">
            {pageSizeOptions.map((size) => (
              <SelectItem key={size} value={String(size)}>
                {size}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      <Pagination aria-label={labels.pagination} className="mx-0 w-auto">
        <PaginationContent>
          {page > 1 && (
            <PaginationItem>
              <Button type="button" variant="outline" size="icon" className="size-8" aria-label={labels.previousPage} onClick={() => setPageIndex(pageIndex - 1)}>
                <ChevronLeft />
              </Button>
            </PaginationItem>
          )}
          {visiblePages(page, pageCount).map((item, index) => (
            <PaginationItem key={index}>
              {item === "ellipsis" ? (
                <PaginationEllipsis className="size-8 text-muted-foreground" />
              ) : (
                <Button
                  type="button"
                  variant={item === page ? "default" : "ghost"}
                  size="icon"
                  className="size-8"
                  aria-current={item === page ? "page" : undefined}
                  onClick={() => setPageIndex(item - 1)}
                >
                  {item}
                </Button>
              )}
            </PaginationItem>
          ))}
          {page < pageCount && (
            <PaginationItem>
              <Button type="button" variant="outline" size="icon" className="size-8" aria-label={labels.nextPage} onClick={() => setPageIndex(pageIndex + 1)}>
                <ChevronRight />
              </Button>
            </PaginationItem>
          )}
        </PaginationContent>
      </Pagination>
    </div>
  );
}

/**
 * Shadcn empty state
 *
 * shadcn's `Empty`, with the `labels.empty` message as its title.
 */
const ShadcnEmpty = ({ message }: EmptySlotProps) => (
  <Empty className="py-10 md:py-10">
    <EmptyHeader>
      <EmptyTitle className="text-base">{message}</EmptyTitle>
    </EmptyHeader>
  </Empty>
);

/**
 * Shadcn error state
 *
 * shadcn's `Empty` with the message as its title and an outline `Button`
 * to retry.
 */
const ShadcnError = ({ message, retryLabel, onRetry }: ErrorSlotProps) => (
  <Empty data-error="" className="py-10 md:py-10">
    <EmptyHeader>
      <EmptyTitle className="text-base">{message}</EmptyTitle>
    </EmptyHeader>
    {onRetry && (
      <EmptyContent>
        <Button type="button" variant="outline" size="sm" onClick={onRetry}>
          {retryLabel}
        </Button>
      </EmptyContent>
    )}
  </Empty>
);

/**
 * Shadcn components
 *
 * The slot map a shadcn/ui project would pass as `components`.
 */
export const shadcnComponents: Partial<DataTableComponents> = {
  Table,
  Head: TableHeader,
  Body: TableBody,
  HeaderRow: TableRow,
  Row: ShadcnRow,
  HeaderCell: TableHead,
  Cell: ShadcnCell,
  Checkbox: ShadcnCheckbox,
  Skeleton: () => <Skeleton className="h-4 w-full" />,
  Empty: ShadcnEmpty,
  Error: ShadcnError,
  Pagination: ShadcnPagination,
  DragHandle: ShadcnDragHandle,
  CellEditor: ShadcnCellEditor,
  FillHandle: ShadcnFillHandle,
};

MUI

This adapter renders DataTable with MUI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @mui/material.

The demo sits inside MUI's provider, as any app built on it already does.

mui.tsx

/**
 * DataTable for MUI
 *
 * A `components` map that renders the slotsmith data table with MUI
 * primitives, tested against MUI v7. Copy the file, keep the parts you want,
 * and pass the map as `components={muiComponents}`; every slot left out keeps
 * its fallback.
 */
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Checkbox from "@mui/material/Checkbox";
import CircularProgress from "@mui/material/CircularProgress";
import IconButton from "@mui/material/IconButton";
import InputBase from "@mui/material/InputBase";
import Pagination from "@mui/material/Pagination";
import Stack from "@mui/material/Stack";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TablePagination from "@mui/material/TablePagination";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { createSvgIcon } from "@mui/material/utils";
import { useId } from "react";
import {
  fallbackComponents,
  type CellEditorSlotProps,
  type CellSlotProps,
  type CheckboxSlotProps,
  type DataTableComponents,
  type DragHandleSlotProps,
  type FillHandleSlotProps,
  type EmptySlotProps,
  type ErrorSlotProps,
  type PaginationSlotProps,
  type RootSlotProps,
  type RowSlotProps,
  type SkeletonSlotProps,
  type SortIconSlotProps,
} from "slotsmith/data-table";

/**
 * Root density and stripes
 *
 * The root carries the table's `data-size` and `data-striped`. `sm`, the data
 * table's default, gives the cells the block padding of MUI's small table,
 * and `default` leaves MUI's own; `striped` tints every other body row with
 * the theme's hover colour. The stripe is written with `:where()` so it
 * weighs less than `TableRow`'s own hover and selected styles, which still
 * win over it.
 */
const rootSx: SxProps<Theme> = (theme) => ({
  "&[data-size=sm] :is(th, td)": { py: 0.75 },
  "&:where([data-striped]) tbody > tr:where(:nth-of-type(even))": { backgroundColor: theme.palette.action.hover },
});

/**
 * MUI root
 *
 * A `Stack` with hidden overflow around the table.
 */
const MuiRoot = (props: RootSlotProps) => <Stack overflow="hidden" sx={rootSx} {...props} />;

/**
 * Row drag styles
 *
 * While a row is lifted (`data-dragging`), it and its visible sub-rows
 * (`data-dragging-child`) are drawn above the rows they pass, with opaque
 * cells so those rows never show through, and one shadow under the block.
 * With reduced motion nothing slides: the block is dimmed and a line on
 * `data-drop-edge` shows where it will land.
 */
const rowDragSx: SxProps<Theme> = (theme) => ({
  "&[data-dragging], &[data-dragging-child]": {
    position: "relative",
    zIndex: 1,
    "& > td": {
      backgroundColor: theme.palette.background.paper,
      backgroundImage: `linear-gradient(${theme.palette.action.hover}, ${theme.palette.action.hover})`,
    },
  },
  "&[data-dragging]": { boxShadow: theme.shadows[4] },
  // Rows dragged in from another table of a reorder group: a line where they would land.
  "&[data-drop-incoming][data-drop-edge=before] > td": { boxShadow: `inset 0 2px 0 0 ${theme.palette.primary.main}` },
  "&[data-drop-incoming][data-drop-edge=after] > td": { boxShadow: `inset 0 -2px 0 0 ${theme.palette.primary.main}` },
  "&[data-dragging]:has(+ [data-dragging-child])": { boxShadow: "none" },
  "&[data-dragging-child]:not(:has(+ [data-dragging-child]))": { boxShadow: theme.shadows[4] },
  "@media (prefers-reduced-motion: reduce)": {
    "&[data-dragging], &[data-dragging-child]": { boxShadow: "none !important", "& > td": { opacity: 0.5 } },
    "&[data-drop-edge=before] > td": { boxShadow: `inset 0 2px 0 0 ${theme.palette.primary.main}` },
    "&[data-drop-edge=after] > td": { boxShadow: `inset 0 -2px 0 0 ${theme.palette.primary.main}` },
  },
});

/**
 * MUI row
 *
 * `TableRow`, with the slot's `data-state="selected"` mapped onto MUI's
 * `selected` prop so the row gets `Mui-selected` styling, and the drag
 * styles for row reordering.
 */
const MuiRow = (props: RowSlotProps) => (
  <TableRow hover selected={props["data-state" as keyof RowSlotProps] === "selected"} sx={rowDragSx} {...props} />
);

/**
 * Drag indicator icon
 *
 * The path of `@mui/icons-material`'s `DragIndicator`, built with
 * `createSvgIcon` so the adapter needs no icons package.
 */
const DragIndicator = createSvgIcon(
  <path d="M11 18c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2m-2-8c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m6 4c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2" />,
  "DragIndicator",
);

/**
 * MUI drag handle
 *
 * A small `IconButton`. Every slot prop goes to its `<button>`: the ref, the
 * pointer and key handlers, the `aria-*` attributes and `disabled`.
 */
const MuiDragHandle = ({ style, ...props }: DragHandleSlotProps) => (
  <IconButton size="small" style={{ cursor: props["data-dragging"] === undefined ? "grab" : "grabbing", ...style }} {...props}>
    <DragIndicator fontSize="small" />
  </IconButton>
);

/**
 * MUI checkbox adapter
 *
 * MUI's `Checkbox` has its own `indeterminate` prop and calls
 * `onChange(event, checked)`.
 */
const MuiCheckbox = ({ checked, indeterminate, disabled, onCheckedChange, tabIndex, ...aria }: CheckboxSlotProps) => (
  <Checkbox
    size="small"
    checked={checked}
    indeterminate={indeterminate}
    disabled={disabled}
    onChange={(_, value) => onCheckedChange(value)}
    onClick={(event) => event.stopPropagation()}
    slotProps={{ input: { "aria-label": aria["aria-label"], tabIndex } }}
  />
);

/**
 * Spreadsheet cell styles
 *
 * In spreadsheet mode a body cell carries its part in the selection as
 * `data-*` attributes: the active cell and the one being edited get a ring
 * in the primary colour, the selected range a primary tint, the cells a
 * fill drag would write and the copied ones a lighter tint with a thin ring.
 * Read-only cells are muted. Cells cannot be selected as text, except in the
 * editor.
 */
const cellSx: SxProps<Theme> = (theme) => ({
  "&[data-column-id]": { cursor: "cell", outline: "none", userSelect: "none", scrollMarginTop: "3em" },
  "&[data-selected]": { backgroundColor: alpha(theme.palette.primary.main, 0.08) },
  "&:is([data-fill-preview], [data-copied])": {
    backgroundColor: alpha(theme.palette.primary.main, 0.04),
    boxShadow: `inset 0 0 0 1px ${alpha(theme.palette.primary.main, 0.5)}`,
  },
  "&:is([data-active], [data-editing])": { boxShadow: `inset 0 0 0 2px ${theme.palette.primary.main}` },
  "&[data-editing]": { backgroundColor: theme.palette.background.paper },
  "&[aria-readonly=true]": { color: theme.palette.text.secondary },
  "& input": { userSelect: "text" },
});

/**
 * MUI cell
 *
 * `TableCell` with the spreadsheet styles.
 */
const MuiCell = (props: CellSlotProps) => <TableCell sx={cellSx} {...props} />;

/**
 * MUI cell editor
 *
 * Text and numbers are edited in an `InputBase`, borderless so the cell
 * itself is the field, with the validation message in an error chip above
 * the cell. Selects and dates keep the built-in editor, the slotsmith
 * `Autocomplete` and `DatePicker`, which wear the MUI adapters given to
 * `SlotsmithProvider`.
 */
function MuiCellEditor(props: CellEditorSlotProps) {
  const errorId = useId();
  if (props.kind !== "text" && props.kind !== "number") return <fallbackComponents.CellEditor {...props} />;
  const { onValueChange, error } = props;
  return (
    <Box sx={{ display: "flex", alignItems: "center", flex: 1, minWidth: 0, px: 2 }}>
      <InputBase
        fullWidth
        value={props.value}
        onChange={(event) => onValueChange(event.target.value)}
        error={error !== null}
        sx={{ font: "inherit" }}
        inputProps={{
          "aria-label": props["aria-label"],
          "aria-invalid": error !== null || undefined,
          "aria-describedby": error !== null ? errorId : undefined,
          inputMode: props.kind === "number" ? "decimal" : undefined,
          autoComplete: "off",
          spellCheck: false,
        }}
      />
      {error !== null && (
        <Typography
          id={errorId}
          role="alert"
          variant="caption"
          sx={{
            position: "absolute",
            bottom: "100%",
            insetInlineStart: 0,
            zIndex: 3,
            mb: 0.25,
            px: 1,
            borderRadius: 1,
            whiteSpace: "nowrap",
            color: "error.contrastText",
            bgcolor: "error.main",
          }}
        >
          {error}
        </Typography>
      )}
    </Box>
  );
}

/**
 * MUI fill handle
 *
 * A small primary square inside the selection's corner, ringed in the paper colour.
 */
const MuiFillHandle = (props: FillHandleSlotProps) => (
  <Box
    component="span"
    sx={{
      position: "absolute",
      zIndex: 2,
      insetBlockEnd: 0,
      insetInlineEnd: 0,
      width: 7,
      height: 7,
      boxSizing: "border-box",
      bgcolor: "primary.main",
      border: 1,
      borderColor: "background.paper",
      cursor: "crosshair",
      touchAction: "none",
    }}
    {...props}
  />
);

/**
 * MUI sort icon
 *
 * Arrows in MUI's secondary text color.
 */
const MuiSortIcon = ({ direction }: SortIconSlotProps) => (
  <Box component="span" sx={{ color: "text.secondary", fontSize: 12 }}>
    {direction === "asc" ? "▲" : direction === "desc" ? "▼" : "↕"}
  </Box>
);

/**
 * MUI loading
 *
 * One `CircularProgress` instead of skeleton rows: this `Skeleton` slot
 * draws it in the first cell only.
 */
const MuiLoading = ({ rowIndex, columnIndex }: SkeletonSlotProps) =>
  rowIndex === 0 && columnIndex === 0 ? <CircularProgress size={24} /> : null;

/**
 * MUI empty state
 *
 * The message in centred secondary `Typography`.
 */
const MuiEmpty = ({ message }: EmptySlotProps) => (
  <Stack alignItems="center" justifyContent="center" spacing={1}>
    <Typography variant="body1" color="text.secondary">
      {message}
    </Typography>
  </Stack>
);

/**
 * MUI error state
 *
 * Message and a retry `Button`.
 */
const MuiError = ({ message, retryLabel, onRetry }: ErrorSlotProps) => (
  <Stack alignItems="center" spacing={1}>
    <Typography color="error">{message}</Typography>
    {onRetry && (
      <Button size="small" onClick={onRetry}>
        {retryLabel}
      </Button>
    )}
  </Stack>
);

/**
 * MUI pagination
 *
 * A pair: `Pagination` for the page numbers, and a `TablePagination` with
 * its actions blanked out for rows-per-page and the "x–y of n" label.
 */
function MuiPagination({ pageIndex, pageCount, pageSize, pageSizeOptions, rowCount, setPageIndex, setPageSize }: PaginationSlotProps) {
  return (
    <Stack direction="row" justifyContent="space-between" alignItems="center" flexWrap="wrap" m={1}>
      <Pagination count={pageCount} page={pageIndex + 1} onChange={(_, page) => setPageIndex(page - 1)} />
      <TablePagination
        component="div"
        ActionsComponent={() => <></>}
        count={rowCount}
        page={pageIndex}
        rowsPerPage={pageSize}
        rowsPerPageOptions={pageSizeOptions}
        onPageChange={() => {}}
        onRowsPerPageChange={(event) => setPageSize(parseInt(event.target.value, 10))}
      />
    </Stack>
  );
}

/**
 * MUI components
 *
 * The slot map an MUI v7 project would pass as `components`.
 */
export const muiComponents: Partial<DataTableComponents> = {
  Root: MuiRoot,
  Table,
  Head: TableHead,
  Body: TableBody,
  HeaderRow: TableRow,
  Row: MuiRow,
  HeaderCell: TableCell,
  Cell: MuiCell,
  Checkbox: MuiCheckbox,
  SortIcon: MuiSortIcon,
  Skeleton: MuiLoading,
  Empty: MuiEmpty,
  Error: MuiError,
  Pagination: MuiPagination,
  DragHandle: MuiDragHandle,
  CellEditor: MuiCellEditor,
  FillHandle: MuiFillHandle,
};

Chakra UI

This adapter renders DataTable with Chakra UI parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @chakra-ui/react.

The demo sits inside Chakra UI's provider, as any app built on it already does.

chakra.tsx

/**
 * DataTable for Chakra UI
 *
 * A `components` map that renders the slotsmith data table with Chakra UI
 * primitives, tested against Chakra UI v3. Copy the file, keep the parts you
 * want, and pass the map as `components={chakraComponents}`; every slot left
 * out keeps its fallback.
 */
import {
  Box,
  Button,
  ButtonGroup,
  Center,
  Checkbox,
  EmptyState,
  HStack,
  IconButton,
  Input,
  NativeSelect,
  Pagination,
  Skeleton,
  Spinner,
  Table,
  Text,
  VStack,
} from "@chakra-ui/react";
import { useId } from "react";
import {
  fallbackComponents,
  type CellEditorSlotProps,
  type CellSlotProps,
  type CheckboxSlotProps,
  type DataTableComponents,
  type DragHandleSlotProps,
  type EmptySlotProps,
  type ErrorSlotProps,
  type FillHandleSlotProps,
  type HeaderCellSlotProps,
  type PaginationSlotProps,
  type RowSlotProps,
  type SectionSlotProps,
  type TableSlotProps,
} from "slotsmith/data-table";

/**
 * Chakra table
 *
 * `Table.Root` renders the `<table>`.
 */
const ChakraTable = (props: TableSlotProps) => <Table.Root {...props} />;

/**
 * Chakra header / body
 *
 * `Table.Header` and `Table.Body`.
 */
const ChakraHead = (props: SectionSlotProps) => <Table.Header {...props} />;
const ChakraBody = (props: SectionSlotProps) => <Table.Body {...props} />;

/**
 * Row drag styles
 *
 * While a row is lifted (`data-dragging`), it and its visible sub-rows
 * (`data-dragging-child`) are drawn above the rows they pass, with opaque
 * cells so those rows never show through, and one shadow under the block.
 * With reduced motion nothing slides: the block is dimmed and a line on
 * `data-drop-edge` shows where it will land.
 */
const rowDragCss = {
  "&[data-dragging], &[data-dragging-child]": { position: "relative", zIndex: 1, "& > td": { bg: "bg.muted" } },
  "&[data-dragging]": { boxShadow: "md" },
  // Rows dragged in from another table of a reorder group: a line where they would land.
  "&[data-drop-incoming][data-drop-edge=before] > td": { boxShadow: "inset 0 2px 0 0 {colors.colorPalette.solid}" },
  "&[data-drop-incoming][data-drop-edge=after] > td": { boxShadow: "inset 0 -2px 0 0 {colors.colorPalette.solid}" },
  "&[data-dragging]:has(+ [data-dragging-child])": { boxShadow: "none" },
  "&[data-dragging-child]:not(:has(+ [data-dragging-child]))": { boxShadow: "md" },
  _motionReduce: {
    "&[data-dragging], &[data-dragging-child]": { boxShadow: "none !important", "& > td": { opacity: 0.5 } },
    "&[data-drop-edge=before] > td": { boxShadow: "inset 0 2px 0 0 {colors.colorPalette.solid}" },
    "&[data-drop-edge=after] > td": { boxShadow: "inset 0 -2px 0 0 {colors.colorPalette.solid}" },
  },
} as const;

/**
 * Chakra row
 *
 * `Table.Row` with a transparent background for a dense table, the selected
 * state mapped onto Chakra's `bg` prop, and the drag styles for row
 * reordering.
 */
const ChakraRow = (props: RowSlotProps) => (
  <Table.Row
    bg={props["data-state" as keyof RowSlotProps] === "selected" ? "bg.muted" : "transparent"}
    css={rowDragCss}
    {...props}
  />
);

/**
 * Chakra drag handle
 *
 * A small ghost `IconButton` with a six-dot grip. Every slot prop goes to its
 * `<button>`: the ref, the pointer and key handlers, the `aria-*`
 * attributes and `disabled`.
 */
const ChakraDragHandle = (props: DragHandleSlotProps) => (
  <IconButton
    variant="ghost"
    size="xs"
    color="fg.muted"
    cursor={props["data-dragging"] === undefined ? "grab" : "grabbing"}
    {...props}
  >
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <circle cx="9" cy="5" r="1.5" />
      <circle cx="9" cy="12" r="1.5" />
      <circle cx="9" cy="19" r="1.5" />
      <circle cx="15" cy="5" r="1.5" />
      <circle cx="15" cy="12" r="1.5" />
      <circle cx="15" cy="19" r="1.5" />
    </svg>
  </IconButton>
);

/**
 * Chakra header cell / cell
 *
 * `Table.ColumnHeader` and `Table.Cell`.
 */
const ChakraHeaderCell = (props: HeaderCellSlotProps) => <Table.ColumnHeader {...props} />;
const ChakraCell = (props: CellSlotProps) => <Table.Cell css={cellCss} {...props} />;

/**
 * Spreadsheet cell styles
 *
 * In spreadsheet mode a body cell carries its part in the selection as
 * `data-*` attributes: the active cell and the one being edited get a ring
 * in the palette's solid colour, the selected range its subtle tint, the
 * cells a fill drag would write and the copied ones a muted tint with a thin
 * ring. Read-only cells are muted. Cells cannot be selected as text, except
 * in the editor.
 */
const cellCss = {
  "&[data-column-id]": { cursor: "cell", outline: "none", userSelect: "none", scrollMarginTop: "3em" },
  "&[data-selected]": { bg: "colorPalette.subtle" },
  "&:is([data-fill-preview], [data-copied])": { bg: "bg.muted", boxShadow: "inset 0 0 0 1px {colors.colorPalette.muted}" },
  "&:is([data-active], [data-editing])": { boxShadow: "inset 0 0 0 2px {colors.colorPalette.solid}" },
  "&[data-editing]": { bg: "bg" },
  "&[aria-readonly=true]": { color: "fg.muted" },
  "& input": { userSelect: "text" },
} as const;

/**
 * Chakra cell editor
 *
 * Text and numbers are edited in an `Input` with no border of its own, so
 * the cell itself is the field, with the validation message on an error chip
 * above the cell. Selects and dates keep the built-in editor, the slotsmith
 * `Autocomplete` and `DatePicker`, which wear the Chakra adapters given to
 * `SlotsmithProvider`.
 */
function ChakraCellEditor(props: CellEditorSlotProps) {
  const errorId = useId();
  if (props.kind !== "text" && props.kind !== "number") return <fallbackComponents.CellEditor {...props} />;
  const { onValueChange, error } = props;
  return (
    <Box display="flex" alignItems="center" flex="1" minW="0" px="3">
      <Input
        size="sm"
        variant="flushed"
        borderBottomWidth="0"
        h="auto"
        minH="0"
        px="0"
        fontSize="inherit"
        outline="none"
        _focusVisible={{ boxShadow: "none", outline: "none" }}
        value={props.value}
        onChange={(event) => onValueChange(event.target.value)}
        inputMode={props.kind === "number" ? "decimal" : undefined}
        autoComplete="off"
        spellCheck={false}
        aria-label={props["aria-label"]}
        aria-invalid={error !== null || undefined}
        aria-describedby={error !== null ? errorId : undefined}
      />
      {error !== null && (
        <Text
          id={errorId}
          role="alert"
          textStyle="xs"
          position="absolute"
          bottom="100%"
          insetInlineStart="0"
          zIndex="3"
          mb="0.5"
          px="2"
          borderRadius="sm"
          whiteSpace="nowrap"
          color="fg.inverted"
          bg="red.solid"
        >
          {error}
        </Text>
      )}
    </Box>
  );
}

/**
 * Chakra fill handle
 *
 * A small square in the palette's solid colour inside the selection's corner, so it never overflows the scroll area.
 */
const ChakraFillHandle = (props: FillHandleSlotProps) => (
  <Box
    as="span"
    position="absolute"
    zIndex="2"
    insetBlockEnd="0"
    insetInlineEnd="0"
    w="7px"
    h="7px"
    boxSizing="border-box"
    bg="colorPalette.solid"
    borderWidth="1px"
    borderColor="bg"
    cursor="crosshair"
    touchAction="none"
    {...props}
  />
);

/**
 * Chakra checkbox adapter
 *
 * Chakra v3's compound checkbox: `checked` takes `"indeterminate"` and
 * `onCheckedChange` receives `{ checked }`.
 */
const ChakraCheckbox = ({ checked, indeterminate, disabled, onCheckedChange, tabIndex, ...aria }: CheckboxSlotProps) => (
  <Checkbox.Root
    size="sm"
    checked={indeterminate ? "indeterminate" : checked}
    disabled={disabled}
    onCheckedChange={(details) => onCheckedChange(details.checked === true)}
    onClick={(event) => event.stopPropagation()}
  >
    <Checkbox.HiddenInput aria-label={aria["aria-label"]} tabIndex={tabIndex} />
    <Checkbox.Control />
  </Checkbox.Root>
);

/**
 * Chakra pagination
 *
 * A compact pagination bar: Chakra's `Pagination.Root` (with `count` as the
 * total row count) for the pages, and a `NativeSelect` for the page size,
 * named by the `labels.rowsPerPage` text beside it. Padding above keeps the
 * bar clear of the last row's border.
 */
function ChakraPagination({ pageIndex, pageSize, pageSizeOptions, rowCount, setPageIndex, setPageSize, labels }: PaginationSlotProps) {
  const rowsPerPageId = useId();
  return (
    <Pagination.Root
      pt="3"
      mb="2"
      mx="2"
      count={rowCount}
      pageSize={pageSize}
      page={pageIndex + 1}
      onPageChange={({ page }) => setPageIndex(page - 1)}
      display="flex"
      justifyContent="space-between"
    >
      <ButtonGroup variant="ghost" size="sm">
        <Pagination.PrevTrigger asChild>
          <IconButton aria-label={labels.previousPage}>‹</IconButton>
        </Pagination.PrevTrigger>
        <Pagination.Items
          render={(page) => <IconButton variant={{ base: "ghost", _selected: "outline" }}>{page.value}</IconButton>}
        />
        <Pagination.NextTrigger asChild>
          <IconButton aria-label={labels.nextPage}>›</IconButton>
        </Pagination.NextTrigger>
      </ButtonGroup>
      <HStack gap="2">
        <Text id={rowsPerPageId} textStyle="sm">
          {labels.rowsPerPage}
        </Text>
        <NativeSelect.Root size="sm" width="auto">
          <NativeSelect.Field
            aria-labelledby={rowsPerPageId}
            value={pageSize}
            onChange={(event) => setPageSize(Number(event.currentTarget.value))}
          >
            {pageSizeOptions.map((size) => (
              <option key={size} value={size}>
                {size}
              </option>
            ))}
          </NativeSelect.Field>
          <NativeSelect.Indicator />
        </NativeSelect.Root>
      </HStack>
    </Pagination.Root>
  );
}

/**
 * Chakra empty state
 *
 * Chakra's `EmptyState` block, centred in the table body.
 */
const ChakraEmpty = ({ message }: EmptySlotProps) => (
  <EmptyState.Root>
    <EmptyState.Content>
      <VStack textAlign="center">
        <EmptyState.Title>{message}</EmptyState.Title>
      </VStack>
    </EmptyState.Content>
  </EmptyState.Root>
);

/**
 * Chakra error state
 *
 * An `EmptyState` with a retry button.
 */
const ChakraError = ({ message, retryLabel, onRetry }: ErrorSlotProps) => (
  <EmptyState.Root>
    <EmptyState.Content>
      <EmptyState.Title>{message}</EmptyState.Title>
      {onRetry && (
        <Button size="sm" onClick={onRetry}>
          {retryLabel}
        </Button>
      )}
    </EmptyState.Content>
  </EmptyState.Root>
);

/**
 * Chakra components
 *
 * The slot map a Chakra UI v3 project would pass as `components`, styled as
 * a dense table.
 */
export const chakraComponents: Partial<DataTableComponents> = {
  Table: ChakraTable,
  Head: ChakraHead,
  Body: ChakraBody,
  HeaderRow: ChakraRow,
  Row: ChakraRow,
  HeaderCell: ChakraHeaderCell,
  Cell: ChakraCell,
  Checkbox: ChakraCheckbox,
  Skeleton: () => <Skeleton height="4" />,
  Empty: ChakraEmpty,
  Error: ChakraError,
  Pagination: ChakraPagination,
  DragHandle: ChakraDragHandle,
  CellEditor: ChakraCellEditor,
  FillHandle: ChakraFillHandle,
};

/**
 * Chakra loading
 *
 * One centred spinner instead of skeleton rows, which suits a dense table:
 * this `Skeleton` slot draws it in the first cell only.
 */
export const ChakraSpinnerSkeleton = ({ rowIndex, columnIndex }: { rowIndex: number; columnIndex: number }) =>
  rowIndex === 0 && columnIndex === 0 ? (
    <Center>
      <Spinner size="sm" />
    </Center>
  ) : null;

Ant Design

This adapter renders DataTable with Ant Design parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @ant-design/icons antd.

The demo sits inside Ant Design's provider, as any app built on it already does.

antd.tsx

/**
 * DataTable for Ant Design
 *
 * A `components` map that renders the slotsmith data table with Ant Design
 * primitives, tested against Ant Design v6. Copy the file, keep the parts you
 * want, and pass the map as `components={antdComponents}`; every slot left out
 * keeps its fallback.
 */
import { CaretDownOutlined, CaretUpOutlined, HolderOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
import { Button, Checkbox, ConfigProvider, Empty, Flex, Input, Segmented, Skeleton, Typography, theme } from "antd";
import type { GlobalToken } from "antd";
import { useContext, useId } from "react";
import {
  fallbackComponents,
  useDataTableContext,
  type CellEditorSlotProps,
  type CellSlotProps,
  type CheckboxSlotProps,
  type DataTableComponents,
  type DragHandleSlotProps,
  type EmptySlotProps,
  type ErrorSlotProps,
  type ExpandToggleSlotProps,
  type FillHandleSlotProps,
  type HeaderCellSlotProps,
  type PageSizeSelectSlotProps,
  type PaginationButtonSlotProps,
  type PaginationSlotProps,
  type RootSlotProps,
  type RowSlotProps,
  type SectionSlotProps,
  type SortIconSlotProps,
  type SortTriggerSlotProps,
  type TableSlotProps,
} from "slotsmith/data-table";

/**
 * Ant Design table parts
 *
 * Built from Ant Design v6 primitives — `Button`, `Checkbox`, `Segmented`,
 * `Skeleton`, `Empty`, `Typography` — never from Ant's own `Table`, which is a
 * competing engine rather than a set of parts. Ant has no table primitives
 * below that `Table`, so the element slots are plain table elements dressed
 * in the theme's tokens, laid out like the built-in look.
 */

/**
 * Table sheet
 *
 * Ant components take no selector-based styles, and the rows change look with
 * the `data-*` attributes the table sets (`data-state`, `data-dragging`,
 * `data-drop-edge`), so the element slots are styled by one sheet built from
 * the theme's tokens. It is scoped to one table, so two tables under
 * different `ConfigProvider` themes keep their own colours.
 *
 * The root's `data-size` picks the cell padding of Ant's own tables: `sm`,
 * the data table's default, matches Ant's small table and `default` its
 * default one. `data-striped` tints every other body row. The stripe is
 * written with `:where()` so it weighs no more than the hover rule, and the
 * hover and selected tints that follow it still win.
 *
 * While a row is lifted (`data-dragging`), it and its visible sub-rows
 * (`data-dragging-child`) are drawn above the rows they pass, with opaque
 * cells so those rows never show through, and one shadow under the block.
 * With reduced motion nothing slides: the block is dimmed and a line on
 * `data-drop-edge` shows where it will land. Rows dragged in from another
 * table of a reorder group (`data-drop-incoming`) get that line too.
 *
 * In spreadsheet mode the body cells carry their part in the selection: the
 * active cell and the one being edited get a ring in the primary colour, the
 * selected range the active-item tint, the cells a fill drag would write and
 * the copied ones the hover tint with a thin ring. Read-only cells are muted.
 *
 * @param scope - The class on this table's root.
 * @param token - The theme's tokens.
 * @returns The sheet's text.
 */
function tableSheet(scope: string, token: GlobalToken) {
  const s = `.${scope}`;
  const rule = `1px solid ${token.colorBorderSecondary}`;
  const lifted = `${s} tbody > tr[data-dragging]`;
  const child = `${s} tbody > tr[data-dragging-child]`;
  return `
${s} { width: 100%; overflow: hidden; color: ${token.colorText}; font-size: ${token.fontSize}px; background: ${token.colorBgContainer}; border: ${rule}; border-radius: ${token.borderRadiusLG}px; }
${s} > div { overflow: auto; }
${s} table { width: 100%; border-collapse: separate; border-spacing: 0; }
${s} th, ${s} td { padding: ${token.paddingXS + 2}px ${token.paddingSM}px; text-align: start; vertical-align: middle; border-bottom: ${rule}; }
${s}[data-size=sm] :is(th, td) { padding: ${token.paddingXS}px; }
${s}[data-size=default] :is(th, td) { padding: ${token.padding}px; }
${s} th { position: sticky; top: 0; z-index: 2; background-color: ${token.colorBgContainer}; font-weight: ${token.fontWeightStrong}; white-space: nowrap; }
${s} :is(th, td):is([data-slot=drag], [data-slot=select]) { width: 1%; padding-inline-end: 0; }
${s} tbody > tr:last-child > td { border-bottom: none; }
${s} tfoot td { font-weight: ${token.fontWeightStrong}; border-top: ${rule}; border-bottom: none; }
${s} tbody > tr > td { transition: background-color ${token.motionDurationMid}; }
${s}:where([data-striped]) tbody > tr:where(:nth-child(even)) > td { background-color: ${token.colorFillAlter}; }
${s} tbody > tr:hover > td { background-color: ${token.controlItemBgHover}; }
${s} tbody > tr[data-state=selected] > td { background-color: ${token.controlItemBgActive}; }
${s} tbody > tr[data-clickable] { cursor: pointer; }
${lifted}, ${child} { position: relative; z-index: 1; }
${lifted} > td, ${child} > td { background-color: ${token.colorBgContainer}; background-image: linear-gradient(${token.controlItemBgHover}, ${token.controlItemBgHover}); }
${lifted} { box-shadow: ${token.boxShadowSecondary}; }
${s} tbody > tr[data-drop-incoming][data-drop-edge=before] > td { box-shadow: inset 0 2px 0 0 ${token.colorPrimary}; }
${s} tbody > tr[data-drop-incoming][data-drop-edge=after] > td { box-shadow: inset 0 -2px 0 0 ${token.colorPrimary}; }
${lifted}:has(+ tr[data-dragging-child]) { box-shadow: none; }
${child}:not(:has(+ tr[data-dragging-child])) { box-shadow: ${token.boxShadowSecondary}; }
${s} tbody > tr > td[data-column-id] { position: relative; cursor: cell; outline: none; user-select: none; scroll-margin-top: 3em; }
${s} tbody > tr > td[data-column-id] input { user-select: text; }
${s} tbody > tr > td[data-column-id][data-selected] { background-color: ${token.controlItemBgActive}; }
${s} tbody > tr > td[data-column-id]:is([data-fill-preview], [data-copied]) { background-color: ${token.controlItemBgHover}; box-shadow: inset 0 0 0 1px ${token.colorPrimaryBorder}; }
${s} tbody > tr > td[data-column-id]:is([data-active], [data-editing]) { box-shadow: inset 0 0 0 2px ${token.colorPrimary}; }
${s} tbody > tr > td[data-column-id][data-editing] { background-color: ${token.colorBgContainer}; }
${s} tbody > tr > td[data-column-id][aria-readonly=true] { color: ${token.colorTextSecondary}; }
@media (prefers-reduced-motion: reduce) {
  ${lifted}, ${child} { box-shadow: none !important; }
  ${lifted} > td, ${child} > td { opacity: 0.5; }
  ${s} tbody > tr[data-drop-edge=before] > td { box-shadow: inset 0 2px 0 0 ${token.colorPrimary}; }
  ${s} tbody > tr[data-drop-edge=after] > td { box-shadow: inset 0 -2px 0 0 ${token.colorPrimary}; }
}`;
}

/**
 * Ant root
 *
 * The bordered surface around the table and its pagination, carrying the
 * scoped sheet. `useId` gives each table its own scope; the characters React
 * wraps it in are not valid in a class name, so they are dropped. The sheet
 * carries the nonce from `ConfigProvider`'s `csp`, as Ant's own styles do,
 * so a strict Content Security Policy lets it through.
 */
function AntRoot({ className, children, ...props }: RootSlotProps) {
  const { token } = theme.useToken();
  const { csp } = useContext(ConfigProvider.ConfigContext);
  const scope = `ss-antd-table-${useId().replace(/[^\w-]/g, "")}`;
  return (
    <div className={className ? `${scope} ${className}` : scope} {...props}>
      <style nonce={csp?.nonce}>{tableSheet(scope, token)}</style>
      {children}
    </div>
  );
}

/**
 * Ant table elements
 *
 * Plain elements, styled by the root's sheet. Replacing the built-in ones
 * keeps slotsmith's own stylesheet, if the app loads it, off these cells.
 */
const AntTable = (props: TableSlotProps) => <table {...props} />;
const AntHead = (props: SectionSlotProps) => <thead {...props} />;
const AntBody = (props: SectionSlotProps) => <tbody {...props} />;
const AntFoot = (props: SectionSlotProps) => <tfoot {...props} />;
const AntRow = (props: RowSlotProps) => <tr {...props} />;
const AntHeaderCell = (props: HeaderCellSlotProps) => <th {...props} />;
const AntCell = (props: CellSlotProps) => <td {...props} />;

/**
 * Right-to-left
 *
 * @returns Whether the nearest `ConfigProvider` lays out right to left, so
 * arrows can point the way the page reads.
 */
const useRtl = () => useContext(ConfigProvider.ConfigContext).direction === "rtl";

/**
 * Ant drag handle
 *
 * A text `Button` with the `HolderOutlined` grip. Every slot prop goes to its
 * `<button>`: the ref, the pointer and key handlers, the `aria-*` attributes
 * and `disabled`. The HTML `type` moves to `htmlType`, since Ant's `type` is
 * the button's variant. `touch-action: none` keeps a touch drag from
 * scrolling the page instead of moving the row.
 */
const AntDragHandle = ({ type, style, ...props }: DragHandleSlotProps) => (
  <Button
    type="text"
    size="small"
    htmlType={type}
    icon={<HolderOutlined />}
    style={{ cursor: props["data-dragging"] === undefined ? "grab" : "grabbing", touchAction: "none", ...style }}
    {...props}
  />
);

/**
 * Ant cell editor
 *
 * Text and numbers are edited in a borderless `Input`, so the cell itself is
 * the field, with the validation message on an error chip above the cell.
 * Selects and dates keep the built-in editor, the slotsmith `Autocomplete`
 * and `DatePicker`, which wear the Ant adapters given to `SlotsmithProvider`.
 */
function AntCellEditor(props: CellEditorSlotProps) {
  const { token } = theme.useToken();
  const errorId = useId();
  if (props.kind !== "text" && props.kind !== "number") return <fallbackComponents.CellEditor {...props} />;
  const { onValueChange, error } = props;
  return (
    <div style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 0, paddingInline: token.paddingXS }}>
      <Input
        variant="borderless"
        value={props.value}
        onChange={(event) => onValueChange(event.target.value)}
        status={error !== null ? "error" : undefined}
        inputMode={props.kind === "number" ? "decimal" : undefined}
        autoComplete="off"
        spellCheck={false}
        aria-label={props["aria-label"]}
        aria-invalid={error !== null || undefined}
        aria-describedby={error !== null ? errorId : undefined}
        style={{ padding: 0, font: "inherit" }}
      />
      {error !== null && (
        <span
          id={errorId}
          role="alert"
          style={{
            position: "absolute",
            bottom: "100%",
            insetInlineStart: 0,
            zIndex: 3,
            marginBottom: 2,
            paddingInline: token.paddingXS,
            borderRadius: token.borderRadiusSM,
            whiteSpace: "nowrap",
            fontSize: token.fontSizeSM,
            color: token.colorWhite,
            background: token.colorError,
          }}
        >
          {error}
        </span>
      )}
    </div>
  );
}

/**
 * Ant fill handle
 *
 * A small square in the primary colour inside the selection's corner, so it never overflows the scroll area.
 */
function AntFillHandle({ style, ...props }: FillHandleSlotProps) {
  const { token } = theme.useToken();
  return (
    <span
      style={{
        position: "absolute",
        zIndex: 2,
        insetBlockEnd: 0,
        insetInlineEnd: 0,
        width: 7,
        height: 7,
        boxSizing: "border-box",
        background: token.colorPrimary,
        border: `1px solid ${token.colorBgContainer}`,
        cursor: "crosshair",
        touchAction: "none",
        ...style,
      }}
      {...props}
    />
  );
}

/**
 * Ant checkbox adapter
 *
 * Ant's `Checkbox` has its own `indeterminate` prop and reports the change
 * on `event.target.checked`. The click is stopped so it does not also count
 * as a click on the row.
 */
const AntCheckbox = ({ checked, indeterminate, disabled, onCheckedChange, tabIndex, ...aria }: CheckboxSlotProps) => (
  <Checkbox
    tabIndex={tabIndex}
    checked={checked}
    indeterminate={indeterminate}
    disabled={disabled}
    onChange={(event) => onCheckedChange(event.target.checked)}
    onClick={(event) => event.stopPropagation()}
    aria-label={aria["aria-label"]}
  />
);

/**
 * Ant sort trigger
 *
 * A text `Button` holding the header and the `SortIcon` slot, pulled back by
 * its own padding so the header text lines up with the cells below.
 */
function AntSortTrigger({ direction, onClick, children }: SortTriggerSlotProps) {
  const { components } = useDataTableContext();
  const { token } = theme.useToken();
  return (
    <Button
      type="text"
      size="small"
      iconPlacement="end"
      icon={<components.SortIcon direction={direction} />}
      onClick={onClick}
      style={{ fontWeight: "inherit", marginInline: -token.paddingXS, paddingInline: token.paddingXS }}
    >
      {children}
    </Button>
  );
}

/**
 * Ant sort icon
 *
 * The stacked carets Ant's own table shows, the active one in the primary
 * colour. Hidden from assistive technology: `aria-sort` on the header
 * already says which way the column is sorted.
 */
function AntSortIcon({ direction }: SortIconSlotProps) {
  const { token } = theme.useToken();
  const tint = (active: boolean) => ({ color: active ? token.colorPrimary : token.colorTextQuaternary });
  return (
    <span aria-hidden="true" style={{ display: "inline-flex", flexDirection: "column", fontSize: token.fontSizeSM - 2 }}>
      <CaretUpOutlined style={tint(direction === "asc")} />
      <CaretDownOutlined style={{ ...tint(direction === "desc"), marginTop: "-0.3em" }} />
    </span>
  );
}

/**
 * Ant expand toggle
 *
 * A text `Button` with a chevron that turns down when the row is open, and
 * points the way the page reads when it is closed.
 */
function AntExpandToggle({ expanded, onToggle, depth: _depth, ...aria }: ExpandToggleSlotProps) {
  const rtl = useRtl();
  const Chevron = rtl ? LeftOutlined : RightOutlined;
  return (
    <Button
      type="text"
      size="small"
      aria-expanded={expanded}
      icon={<Chevron rotate={expanded ? (rtl ? -90 : 90) : 0} />}
      onClick={(event) => {
        event.stopPropagation();
        onToggle();
      }}
      {...aria}
    />
  );
}

/**
 * Ant skeleton
 *
 * An active `Skeleton.Input` in every cell, the shimmering bars of the
 * built-in look drawn by Ant.
 */
const AntSkeleton = () => <Skeleton.Input active size="small" block />;

/**
 * Ant empty state
 *
 * Ant's simple `Empty` picture over the message.
 */
const AntEmpty = ({ message }: EmptySlotProps) => <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={message} />;

/**
 * Ant error state
 *
 * The message in the danger colour and a retry `Button`, announced as an
 * alert like the built-in one.
 */
function AntError({ message, retryLabel, onRetry }: ErrorSlotProps) {
  const { token } = theme.useToken();
  return (
    <Flex vertical align="center" gap="small" role="alert" style={{ paddingBlock: token.paddingLG }}>
      <Typography.Text type="danger">{message}</Typography.Text>
      {onRetry && (
        <Button size="small" onClick={onRetry}>
          {retryLabel}
        </Button>
      )}
    </Flex>
  );
}

/**
 * Ant pagination button
 *
 * A `Button` with a chevron that points the way the page reads.
 */
function AntPaginationButton({ direction, ...props }: PaginationButtonSlotProps) {
  const backwards = (direction === "previous") !== useRtl();
  return <Button size="small" icon={backwards ? <LeftOutlined /> : <RightOutlined />} {...props} />;
}

/**
 * Ant page-size select
 *
 * A `Segmented` control over the handful of page sizes, named by the visible
 * label beside it.
 */
function AntPageSizeSelect({ value, options, onValueChange, label }: PageSizeSelectSlotProps) {
  const id = useId();
  return (
    <Flex align="center" gap="small">
      <Typography.Text type="secondary" id={id}>
        {label}
      </Typography.Text>
      <Segmented<number> size="small" aria-labelledby={id} options={options} value={value} onChange={onValueChange} />
    </Flex>
  );
}

/**
 * Ant pagination
 *
 * The built-in bar's layout — previous and next, the page info, the page
 * size — under a hairline, composing the `PaginationButton` and
 * `PageSizeSelect` slots so either can still be swapped on its own.
 */
function AntPagination({ labels, ...props }: PaginationSlotProps) {
  const { components } = useDataTableContext();
  const { token } = theme.useToken();
  return (
    <Flex
      component="nav"
      aria-label={labels.pagination}
      wrap
      align="center"
      justify="space-between"
      gap="small"
      style={{ padding: `${token.paddingXS}px ${token.paddingSM}px`, borderTop: `1px solid ${token.colorBorderSecondary}` }}
    >
      <Flex gap="small">
        <components.PaginationButton
          direction="previous"
          disabled={!props.canPreviousPage}
          onClick={props.previousPage}
          aria-label={labels.previousPage}
        />
        <components.PaginationButton
          direction="next"
          disabled={!props.canNextPage}
          onClick={props.nextPage}
          aria-label={labels.nextPage}
        />
      </Flex>
      <Typography.Text type="secondary" aria-live="polite">
        {labels.pageInfo(props.pageIndex + 1, props.pageCount)}
      </Typography.Text>
      <components.PageSizeSelect
        value={props.pageSize}
        options={props.pageSizeOptions}
        onValueChange={props.setPageSize}
        label={labels.rowsPerPage}
      />
    </Flex>
  );
}

/**
 * Ant Design components
 *
 * The slot map an Ant Design v6 project would pass as `components`.
 */
export const antdComponents: Partial<DataTableComponents> = {
  Root: AntRoot,
  Table: AntTable,
  Head: AntHead,
  Body: AntBody,
  Foot: AntFoot,
  HeaderRow: AntRow,
  Row: AntRow,
  FooterRow: AntRow,
  HeaderCell: AntHeaderCell,
  Cell: AntCell,
  FooterCell: AntCell,
  Checkbox: AntCheckbox,
  SortTrigger: AntSortTrigger,
  SortIcon: AntSortIcon,
  ExpandToggle: AntExpandToggle,
  Skeleton: AntSkeleton,
  Empty: AntEmpty,
  Error: AntError,
  Pagination: AntPagination,
  PaginationButton: AntPaginationButton,
  PageSizeSelect: AntPageSizeSelect,
  DragHandle: AntDragHandle,
  CellEditor: AntCellEditor,
  FillHandle: AntFillHandle,
};

Radix Themes

This adapter renders DataTable with Radix Themes parts. Copy the file into your app and pass the map to components; every slot you leave out keeps its fallback. It imports @radix-ui/react-icons @radix-ui/themes.

The demo sits inside Radix Themes's provider, as any app built on it already does.

radix.tsx

/**
 * DataTable for Radix Themes
 *
 * A `components` map that renders the slotsmith data table with Radix Themes
 * primitives, tested against Radix Themes v3. Copy the file, keep the parts
 * you want, and pass the map as `components={radixComponents}`; every slot left
 * out keeps its fallback.
 *
 * This is Radix Themes (`@radix-ui/themes`), the styled library; an app on the
 * bare Radix primitives wants the shadcn/ui adapter, which is built on them.
 */
import {
  ArrowDownIcon,
  ArrowUpIcon,
  CaretSortIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  DragHandleDots2Icon,
} from "@radix-ui/react-icons";
import { Button, Checkbox, Flex, IconButton, SegmentedControl, Skeleton, Table, Text, TextField } from "@radix-ui/themes";
import { useId } from "react";
import {
  fallbackComponents,
  useDataTableContext,
  type CellEditorSlotProps,
  type CellSlotProps,
  type CheckboxSlotProps,
  type DataTableComponents,
  type DragHandleSlotProps,
  type EmptySlotProps,
  type ErrorSlotProps,
  type ExpandToggleSlotProps,
  type FillHandleSlotProps,
  type HeaderCellSlotProps,
  type PageSizeSelectSlotProps,
  type PaginationButtonSlotProps,
  type PaginationSlotProps,
  type RootSlotProps,
  type RowSlotProps,
  type SectionSlotProps,
  type SortIconSlotProps,
  type SortTriggerSlotProps,
  type TableSlotProps,
} from "slotsmith/data-table";

/**
 * Radix Themes table parts
 *
 * Built from Radix Themes v3 — `Table.*`, `Button`, `IconButton`,
 * `Checkbox`, `SegmentedControl`, `Skeleton`, `Text` and Radix icons. The
 * colours are Radix's own variables (`--accent-*`, `--gray-*`,
 * `--color-panel-solid`), so the table follows the app's
 * `<Theme accentColor appearance>`. The app must import
 * `@radix-ui/themes/styles.css`, as every Radix Themes app already does.
 *
 * `Table.Root` draws its own `<table>` inside its own scroll area, while the
 * data table owns its scroll box and its `<table>`, so the root and the table
 * take `Table.Root`'s classes instead (`rt-TableRoot`, `rt-TableRootTable`);
 * the sections, rows and cells are `Table.*` components as they are.
 *
 * Radix's `Button` and `IconButton` render a `<button>` with no `type`, which
 * would submit a surrounding form, so each one here is given
 * `type="button"` ahead of the slot's props.
 */

/**
 * Table sheet
 *
 * Radix Themes has no props for hover, for the selected row or for the
 * `data-*` attributes the table sets while a row is dragged (`data-dragging`,
 * `data-dragging-child`, `data-drop-edge`), so those looks come from one
 * sheet scoped to the table. Radix Themes offers no nonce setting either: an
 * app with a strict `style-src` policy adds its nonce to the `<style>` here.
 *
 * While a row is lifted, it and its visible sub-rows are drawn above the rows
 * they pass, with opaque cells so those rows never show through, and one
 * shadow under the block. With reduced motion nothing slides: the block is
 * dimmed and a line on `data-drop-edge` shows where it will land.
 *
 * Arrows marked `data-flip` turn round on a right-to-left page.
 *
 * In spreadsheet mode the body cells carry their part in the selection: the
 * active cell and the one being edited get a ring in the accent colour, the
 * selected range the accent tint, the cells a fill drag would write and the
 * copied ones a grey tint with a thin accent ring. Read-only cells are muted.
 * The editor's `TextField` loses its own box, so the cell is the field.
 *
 * @param scope - The class on this table's root.
 * @returns The sheet's text.
 */
function tableSheet(scope: string) {
  const s = `.${scope}`;
  const lifted = `${s} tbody > tr[data-dragging]`;
  const child = `${s} tbody > tr[data-dragging-child]`;
  return `
${s} { overflow: hidden; }
${s} > div { overflow: auto; }
${s} th { position: sticky; top: 0; z-index: 2; background-color: var(--color-panel-solid); background-image: linear-gradient(var(--gray-a2), var(--gray-a2)); white-space: nowrap; }
${s} :is(th, td):is([data-slot=drag], [data-slot=select]) { width: 1%; padding-inline-end: 0; }
${s} tfoot td { font-weight: var(--font-weight-bold); box-shadow: inset 0 1px var(--gray-a5); }
${s} tbody > tr:hover > td { background-color: var(--gray-a2); }
${s} tbody > tr[data-state=selected] > td { background-color: var(--accent-a3); }
${s} tbody > tr[data-clickable] { cursor: pointer; }
${lifted}, ${child} { position: relative; z-index: 1; }
${lifted} > td, ${child} > td { background-color: var(--color-panel-solid); background-image: linear-gradient(var(--gray-a3), var(--gray-a3)); }
${lifted} { box-shadow: var(--shadow-4); }
${s} tbody > tr[data-drop-incoming][data-drop-edge=before] > td { box-shadow: inset 0 2px 0 0 var(--accent-9); }
${s} tbody > tr[data-drop-incoming][data-drop-edge=after] > td { box-shadow: inset 0 -2px 0 0 var(--accent-9); }
${lifted}:has(+ tr[data-dragging-child]) { box-shadow: none; }
${child}:not(:has(+ tr[data-dragging-child])) { box-shadow: var(--shadow-4); }
[dir=rtl] ${s} [data-flip], ${s}[dir=rtl] [data-flip] { transform: scaleX(-1); }
${s} tbody > tr > td[data-column-id] { position: relative; cursor: cell; outline: none; user-select: none; scroll-margin-top: 3em; }
${s} tbody > tr > td[data-column-id] input { user-select: text; }
${s} tbody > tr > td[data-column-id][data-selected] { background-color: var(--accent-a3); }
${s} tbody > tr > td[data-column-id]:is([data-fill-preview], [data-copied]) { background-color: var(--gray-a3); box-shadow: inset 0 0 0 1px var(--accent-a7); }
${s} tbody > tr > td[data-column-id]:is([data-active], [data-editing]) { box-shadow: inset 0 0 0 2px var(--accent-9); }
${s} tbody > tr > td[data-column-id][data-editing] { background-color: var(--color-panel-solid); }
${s} tbody > tr > td[data-column-id][aria-readonly=true] { color: var(--gray-11); }
${s} td[data-editing] .rt-TextFieldRoot, ${s} td[data-editing] .rt-TextFieldRoot:focus-within { background: none; box-shadow: none; outline: none; padding: 0; }
@media (prefers-reduced-motion: reduce) {
  ${lifted}, ${child} { box-shadow: none !important; }
  ${lifted} > td, ${child} > td { opacity: 0.5; }
  ${s} tbody > tr[data-drop-edge=before] > td { box-shadow: inset 0 2px 0 0 var(--accent-9); }
  ${s} tbody > tr[data-drop-edge=after] > td { box-shadow: inset 0 -2px 0 0 var(--accent-9); }
}`;
}

/**
 * Radix root
 *
 * The surface `Table.Root` draws, around the table and its pagination,
 * carrying the scoped sheet. `useId` gives each table its own scope; the
 * characters React wraps it in are not valid in a class name, so they are
 * dropped.
 */
function RadixRoot({ className, children, ...props }: RootSlotProps) {
  const scope = `ss-radix-table-${useId().replace(/[^\w-]/g, "")}`;
  return (
    <div className={[scope, "rt-TableRoot rt-r-size-2 rt-variant-surface", className].filter(Boolean).join(" ")} {...props}>
      <style>{tableSheet(scope)}</style>
      {children}
    </div>
  );
}

/**
 * Radix table elements
 *
 * The `<table>` takes `Table.Root`'s table class; the rest are `Table.*`
 * parts. Radix Themes drops the DOM `color` attribute from these, since its
 * own `color` prop is an accent, so it is left out here too. Radix also reads
 * a cell's `width` as a layout prop and turns it into a style, so a DOM
 * `width` given through `slotProps.cell` or `slotProps.footerCell` is not
 * forwarded; set a cell's width with `style` or a class instead. Radix has no
 * footer section, so the foot is a plain `<tfoot>` of `Table.Cell`s.
 */
const RadixTable = ({ className, ...props }: TableSlotProps) => (
  <table className={className ? `rt-TableRootTable ${className}` : "rt-TableRootTable"} {...props} />
);
const RadixHead = ({ color: _color, ...props }: SectionSlotProps) => <Table.Header {...props} />;
const RadixBody = ({ color: _color, ...props }: SectionSlotProps) => <Table.Body {...props} />;
const RadixFoot = (props: SectionSlotProps) => <tfoot {...props} />;
const RadixRow = ({ color: _color, ...props }: RowSlotProps) => <Table.Row {...props} />;
const RadixHeaderCell = ({ color: _color, ...props }: HeaderCellSlotProps) => <Table.ColumnHeaderCell {...props} />;
const RadixCell = ({ color: _color, width: _width, ...props }: CellSlotProps) => <Table.Cell {...props} />;

/**
 * Radix drag handle
 *
 * A ghost `IconButton` with the `DragHandleDots2Icon` grip. Every slot prop
 * goes to its `<button>`: the ref, the pointer and key handlers, the `aria-*`
 * attributes and `disabled`. `touch-action: none` keeps a touch drag from
 * scrolling the page instead of moving the row.
 */
const RadixDragHandle = ({ style, ...props }: DragHandleSlotProps) => (
  <IconButton
    type="button"
    variant="ghost"
    color="gray"
    size="1"
    style={{ cursor: props["data-dragging"] === undefined ? "grab" : "grabbing", touchAction: "none", ...style }}
    {...props}
  >
    <DragHandleDots2Icon />
  </IconButton>
);

/**
 * Radix checkbox adapter
 *
 * Radix's `Checkbox` has no `indeterminate` prop: it reports the mixed state
 * as the checked value `"indeterminate"`. So the slot's flag goes in as that
 * value, and a change comes out as a plain boolean; from the mixed state
 * Radix moves to `true`, which selects everything. The click is stopped so
 * it does not also count as a click on the row.
 */
const RadixCheckbox = ({ checked, indeterminate, disabled, onCheckedChange, tabIndex, ...aria }: CheckboxSlotProps) => (
  <Checkbox
    tabIndex={tabIndex}
    checked={indeterminate ? "indeterminate" : checked}
    disabled={disabled}
    onCheckedChange={(state) => onCheckedChange(state === true)}
    onClick={(event) => event.stopPropagation()}
    aria-label={aria["aria-label"]}
  />
);

/**
 * Radix cell editor
 *
 * Text and numbers are edited in a `TextField` that the root's sheet strips
 * of its box, so the cell itself is the field, with the validation message on
 * an error chip above the cell. Selects and dates keep the built-in editor,
 * the slotsmith `Autocomplete` and `DatePicker`, which wear the Radix
 * adapters given to `SlotsmithProvider`.
 */
function RadixCellEditor(props: CellEditorSlotProps) {
  const errorId = useId();
  if (props.kind !== "text" && props.kind !== "number") return <fallbackComponents.CellEditor {...props} />;
  const { onValueChange, error } = props;
  return (
    <Flex align="center" flexGrow="1" minWidth="0" px="3">
      <TextField.Root
        size="2"
        variant="soft"
        color={error !== null ? "red" : undefined}
        style={{ flex: 1 }}
        value={props.value}
        onChange={(event) => onValueChange(event.target.value)}
        inputMode={props.kind === "number" ? "decimal" : undefined}
        autoComplete="off"
        spellCheck={false}
        aria-label={props["aria-label"]}
        aria-invalid={error !== null || undefined}
        aria-describedby={error !== null ? errorId : undefined}
      />
      {error !== null && (
        <Text
          id={errorId}
          role="alert"
          size="1"
          style={{
            position: "absolute",
            bottom: "100%",
            insetInlineStart: 0,
            zIndex: 3,
            marginBottom: 2,
            paddingInline: "var(--space-2)",
            borderRadius: "var(--radius-2)",
            whiteSpace: "nowrap",
            color: "var(--red-contrast)",
            background: "var(--red-9)",
          }}
        >
          {error}
        </Text>
      )}
    </Flex>
  );
}

/**
 * Radix fill handle
 *
 * A small square in the accent colour inside the selection's corner, so it never overflows the scroll area.
 */
const RadixFillHandle = ({ style, ...props }: FillHandleSlotProps) => (
  <span
    style={{
      position: "absolute",
      zIndex: 2,
      insetBlockEnd: 0,
      insetInlineEnd: 0,
      width: 7,
      height: 7,
      boxSizing: "border-box",
      background: "var(--accent-9)",
      border: "1px solid var(--color-panel-solid)",
      cursor: "crosshair",
      touchAction: "none",
      ...style,
    }}
    {...props}
  />
);

/**
 * Radix sort trigger
 *
 * A ghost `Button` holding the header and the `SortIcon` slot. Radix pulls
 * ghost buttons back by their own padding, so the header text lines up with
 * the cells below.
 */
function RadixSortTrigger({ direction, onClick, children }: SortTriggerSlotProps) {
  const { components } = useDataTableContext();
  return (
    <Button type="button" variant="ghost" color="gray" size="2" highContrast onClick={onClick} style={{ fontWeight: "inherit" }}>
      {children}
      <components.SortIcon direction={direction} />
    </Button>
  );
}

/**
 * Radix sort icon
 *
 * An arrow in the accent colour on a sorted column, the muted up-and-down
 * caret on the others. Hidden from assistive technology: `aria-sort` on the
 * header already says which way the column is sorted.
 */
const RadixSortIcon = ({ direction }: SortIconSlotProps) => (
  <span
    aria-hidden="true"
    data-sort-icon=""
    style={{ display: "inline-flex", color: direction ? "var(--accent-11)" : "var(--gray-a8)" }}
  >
    {direction === "asc" ? <ArrowUpIcon /> : direction === "desc" ? <ArrowDownIcon /> : <CaretSortIcon />}
  </span>
);

/**
 * Radix expand toggle
 *
 * A ghost `IconButton` with a chevron that turns down when the row is open,
 * and points the way the page reads when it is closed.
 */
const RadixExpandToggle = ({ expanded, onToggle, depth: _depth, ...aria }: ExpandToggleSlotProps) => (
  <IconButton
    type="button"
    variant="ghost"
    color="gray"
    size="1"
    aria-expanded={expanded}
    onClick={(event) => {
      event.stopPropagation();
      onToggle();
    }}
    {...aria}
  >
    <span data-flip="" style={{ display: "inline-flex" }}>
      <ChevronRightIcon style={{ transform: expanded ? "rotate(90deg)" : undefined, transition: "transform 150ms" }} />
    </span>
  </IconButton>
);

/**
 * Radix skeleton
 *
 * A `Skeleton` bar in every cell, Radix's own pulse.
 */
const RadixSkeleton = () => <Skeleton width="100%" height="1em" />;

/**
 * Radix empty state
 *
 * The message in muted text, centred.
 */
const RadixEmpty = ({ message }: EmptySlotProps) => (
  <Flex justify="center" py="5">
    <Text size="2" color="gray">
      {message}
    </Text>
  </Flex>
);

/**
 * Radix error state
 *
 * The message in red and a soft retry `Button`, announced as an alert like
 * the built-in one.
 */
const RadixError = ({ message, retryLabel, onRetry }: ErrorSlotProps) => (
  <Flex direction="column" align="center" gap="2" py="5" role="alert">
    <Text size="2" color="red">
      {message}
    </Text>
    {onRetry && (
      <Button type="button" size="1" variant="soft" onClick={onRetry}>
        {retryLabel}
      </Button>
    )}
  </Flex>
);

/**
 * Radix pagination button
 *
 * A soft `IconButton` with a chevron that points the way the page reads.
 */
const RadixPaginationButton = ({ direction, ...props }: PaginationButtonSlotProps) => (
  <IconButton type="button" size="1" variant="soft" color="gray" {...props}>
    <span data-flip="" style={{ display: "inline-flex" }}>
      {direction === "previous" ? <ChevronLeftIcon /> : <ChevronRightIcon />}
    </span>
  </IconButton>
);

/**
 * Radix page-size select
 *
 * A `SegmentedControl` over the handful of page sizes, named by the visible
 * label beside it. Its values are strings, so the sizes are turned to and
 * from numbers.
 */
function RadixPageSizeSelect({ value, options, onValueChange, label }: PageSizeSelectSlotProps) {
  const id = useId();
  return (
    <Flex align="center" gap="2">
      <Text size="2" color="gray" id={id}>
        {label}
      </Text>
      <SegmentedControl.Root size="1" aria-labelledby={id} value={String(value)} onValueChange={(size) => onValueChange(Number(size))}>
        {options.map((size) => (
          <SegmentedControl.Item key={size} value={String(size)}>
            {size}
          </SegmentedControl.Item>
        ))}
      </SegmentedControl.Root>
    </Flex>
  );
}

/**
 * Radix pagination
 *
 * The built-in bar's layout — previous and next, the page info, the page
 * size — under the table's own hairline, composing the `PaginationButton`
 * and `PageSizeSelect` slots so either can still be swapped on its own.
 */
function RadixPagination({ labels, ...props }: PaginationSlotProps) {
  const { components } = useDataTableContext();
  return (
    <Flex asChild wrap="wrap" align="center" justify="between" gap="2" px="3" py="2" style={{ boxShadow: "inset 0 1px var(--gray-a5)" }}>
      <nav aria-label={labels.pagination}>
        <Flex gap="2">
          <components.PaginationButton
            direction="previous"
            disabled={!props.canPreviousPage}
            onClick={props.previousPage}
            aria-label={labels.previousPage}
          />
          <components.PaginationButton
            direction="next"
            disabled={!props.canNextPage}
            onClick={props.nextPage}
            aria-label={labels.nextPage}
          />
        </Flex>
        <Text size="2" color="gray" aria-live="polite">
          {labels.pageInfo(props.pageIndex + 1, props.pageCount)}
        </Text>
        <components.PageSizeSelect
          value={props.pageSize}
          options={props.pageSizeOptions}
          onValueChange={props.setPageSize}
          label={labels.rowsPerPage}
        />
      </nav>
    </Flex>
  );
}

/**
 * Radix Themes components
 *
 * The slot map a Radix Themes v3 project would pass as `components`.
 */
export const radixComponents: Partial<DataTableComponents> = {
  Root: RadixRoot,
  Table: RadixTable,
  Head: RadixHead,
  Body: RadixBody,
  Foot: RadixFoot,
  HeaderRow: RadixRow,
  Row: RadixRow,
  FooterRow: RadixRow,
  HeaderCell: RadixHeaderCell,
  Cell: RadixCell,
  FooterCell: RadixCell,
  Checkbox: RadixCheckbox,
  SortTrigger: RadixSortTrigger,
  SortIcon: RadixSortIcon,
  ExpandToggle: RadixExpandToggle,
  Skeleton: RadixSkeleton,
  Empty: RadixEmpty,
  Error: RadixError,
  Pagination: RadixPagination,
  PaginationButton: RadixPaginationButton,
  PageSizeSelect: RadixPageSizeSelect,
  DragHandle: RadixDragHandle,
  CellEditor: RadixCellEditor,
  FillHandle: RadixFillHandle,
};

Last updated