The slot model is how slotsmith components are built: each is made of named slots, and its components prop replaces any of them. An element part receives one element’s props; a widget part is told the state and renders what it likes. Slots you leave alone keep their fallbacks.

The data table, part by part

Turn on “Show parts” to pull the table apart. The list under it names every slot the data table has, read from its source when the site is built; each links to the slot’s row on the API page.

Amir NasserAnalystGrowth
Chloe MartinEngineerPayments
Diego AlvarezEngineerGrowth
Hana KimManagerCare
Jonas WeberSupportCare
12 people

Point at or focus a part to outline it in the table. Each one links to its row on the API page.

<Name>
element part: takes the element's props
{Name}
widget part: told what is true

Two kinds

Element parts stand for one element. <Row> gets the props of a <tr>, <Cell> those of a <td>, <Root> those of a <div>. Pass a component that renders that element and spread every prop onto it: they carry the classes, the data-* state below, the handlers and the ARIA attributes the table relies on.

import { DataTable, type CellSlotProps, type RowSlotProps } from "slotsmith/data-table";
import { columns, people } from "../shared/people";

// An element part gets the element's own props. Spread all of them: they
// carry the data-* state, the handlers and the ARIA attributes.
function Row({ className, ...props }: RowSlotProps) {
  return <tr {...props} className={["app-row", className].filter(Boolean).join(" ")} />;
}

function Cell({ className, ...props }: CellSlotProps) {
  return <td {...props} className={["app-cell", className].filter(Boolean).join(" ")} />;
}

export default function ElementPart() {
  return <DataTable data={people} columns={columns} components={{ Row, Cell }} />;
}

A component that already takes those props, such as shadcn/ui’s TableRow or MUI’s TableRow, fits as it is: components={{ Row: TableRow }}. <DragHandle> is an element part too, a <button> whose props are the row reorder engine’s handlers, so spreading them is what makes the drag work.

Widget parts stand for a control. {Checkbox} gets checked, indeterminate, disabled, onCheckedChange and its aria-label; {Pagination} gets the page, the page count, whether a previous or next page exists and the functions that go there. The markup is yours, so a design system’s own checkbox or pager plugs in through a small adapter.

import { DataTable, type PaginationSlotProps } from "slotsmith/data-table";
import { columns, people } from "../shared/people";

// A widget part is told what is true (the page, the page count, what can
// happen next) and decides the markup itself.
function Pager({ pageIndex, pageCount, canPreviousPage, canNextPage, previousPage, nextPage, labels }: PaginationSlotProps) {
  return (
    <nav aria-label="Pages" className="app-pager">
      <button type="button" onClick={previousPage} disabled={!canPreviousPage} aria-label={labels.previousPage}>
        ←
      </button>
      <span>{labels.pageInfo(pageIndex + 1, pageCount)}</span>
      <button type="button" onClick={nextPage} disabled={!canNextPage} aria-label={labels.nextPage}>
        →
      </button>
    </nav>
  );
}

export default function WidgetPart() {
  return <DataTable data={people} columns={columns} components={{ Pagination: Pager }} />;
}

The data table has 12 element parts and 10 widget parts. Each slot’s props are listed on the API page, and the adapters show the full maps for five design systems.

Data state

Element parts show their state as data-* attributes rather than class names, so you style them with attribute selectors and any design system that already reads them (shadcn/ui’s TableRow styles data-[state=selected]) picks them up. From the data table’s source:

  • <Root>: data-status is loading, error, empty or ready; data-size, data-striped, data-virtual for the virtual table, and aria-busy while loading.
  • <HeaderCell>: data-sorted is asc or desc on the sorted column, beside aria-sort; data-align follows the column’s meta.align.
  • <Row>: body rows carry data-state="selected", data-depth, data-expanded (true or false on rows that can expand), data-clickable with onRowClick, and data-row-id. During a drag they add data-dragging, data-dragging-child, data-drop-position and data-drop-edge. The status rows carry data-state="loading", "empty" or "error".
  • <Cell>: data-align, and data-slot="select" or "drag" on the checkbox and drag-handle cells.
/* Selected body rows. */
.app-table tbody tr[data-state="selected"] {
  background: var(--selected-row);
}

/* Status rows: skeletons while loading, the empty and error messages. */
.app-table tr[data-state="loading"] td {
  opacity: 0.6;
}

/* The column the table is sorted by: data-sorted is "asc" or "desc". */
.app-table th[data-sorted] {
  color: var(--accent);
}

/* Tree rows: data-depth is the level, data-expanded "true" or "false". */
.app-table tr[data-expanded="true"] td:first-child {
  font-weight: 600;
}

/* The whole table while it loads, is empty or has failed. */
.app-table[data-status="loading"] {
  cursor: progress;
}

Widget parts get the same facts as props instead, such as the direction of {SortIcon}. The API page lists every attribute under its slot.

Wrap fallbacks

The built-in parts are exported as fallbackComponents. To change one thing about a part, render its fallback inside your own component rather than rewriting it: add a class, put something around it, or pass different props.

import { DataTable, fallbackComponents, type EmptySlotProps, type RowSlotProps } from "slotsmith/data-table";
import { columns } from "../shared/people";

// Keep a fallback and change one thing: add a class to the stock row.
function Row(props: RowSlotProps) {
  return <fallbackComponents.Row {...props} className={["app-row", props.className].filter(Boolean).join(" ")} />;
}

// Or put something around it: the stock empty message, with a way out under it.
function Empty(props: EmptySlotProps) {
  return (
    <>
      <fallbackComponents.Empty {...props} />
      <a href="#invite">Invite someone</a>
    </>
  );
}

export default function WrapFallback() {
  return <DataTable data={[]} columns={columns} components={{ Row, Empty }} />;
}

Merge the className you are given with your own, as the sample does, or the fallback loses its own class and with it the stylesheet’s rules.

Once, for the whole app

To replace a slot everywhere, set it once on SlotsmithProvider instead of on every component. Its components prop takes one map per component, under the keys dataTable, autocomplete, datePicker and fileUploader. Each is the same map that component’s own components prop takes, so an adapter works in either place.

Below, one provider gives every data table a round checkbox and the date picker a diamond icon. The second table passes its own Checkbox, and that one wins:

Set on the provider: round.

Lena ParkEngineerPlatform
Omar HaddadDesignerProduct
Sofia ReyesEngineerPayments

Set on this table: square.

Lena ParkEngineerPlatform
Omar HaddadDesignerProduct
Sofia ReyesEngineerPayments

Each slot resolves on its own, in three layers, and the later one wins:

  1. The built-in fallback.
  2. The components of the providers above, for that component.
  3. The component’s own components prop.
  • Nested providers merge. An inner provider adds to the outer one component by component, then slot by slot: its slots win, and what it does not name is inherited.
  • The variants read the same maps. VirtualDataTable, VirtualAutocomplete and VirtualFileUploader use their component’s key, and so does a layout built from the compound parts.
  • The page-size select follows autocomplete. The data table’s built-in page-size select is a slotsmith Autocomplete, so it renders with the provider’s autocomplete slots. A PageSizeSelect slot, from dataTable or the table’s own prop, replaces it altogether.
  • Keep the map’s identity. Define it outside your component, as the sample does, or memoize it. slotProps stay on each component.

SlotsmithProvider and the types SlotsmithComponents and SlotsmithComponentName come from slotsmith/provider. The same provider sets the language.

Compound parts

<DataTable> is a fixed layout: a root holding the table and the pagination. For another layout, build it from the compound parts inside DataTable.Provider. The provider takes the data, the state options and components, labels, locale, slotProps and onRowClick, and renders no element of its own; size, striped and HTML attributes go on DataTable.Root, and hidePagination belongs to <DataTable> alone (leave DataTable.Pagination out instead). The parts are DataTable.Root, DataTable.Table, DataTable.Head, DataTable.Body, DataTable.Foot, DataTable.Row, DataTable.StatusRows, DataTable.Pagination and DataTable.DragHandle. useDataTableContext() reads the same model from any component among them.

Lena ParkEngineerPlatformActive2021-03-08
Omar HaddadDesignerProductActive2020-11-16
Sofia ReyesEngineerPaymentsAway2022-01-24
Yuki TanakaManagerPlatformActive2019-06-03

0 of 12 selected

These are layout parts, not slots: each still renders the slots inside it, so components keeps working. The autocomplete, the date picker and the file uploader have their own; see the autocomplete’s compound parts and the date picker’s.

Headless hooks

Under every component is a hook with the state and no markup: useDataTable, useAutocomplete, useDatePicker and useFileUploader, each exported from its component’s entry. <DataTable> calls useDataTable for you; call it yourself when no slot replacement gets you the markup you need.

import { flexRender } from "@tanstack/react-table";
import { useDataTable } from "slotsmith/data-table";
import { columns, people } from "../shared/people";

// No slots, no markup: the hook gives the state and the TanStack instance,
// and the list below is entirely this file's own.
export default function HeadlessHooks() {
  const { table, status } = useDataTable({ data: people, columns, defaultPagination: { pageIndex: 0, pageSize: 3 } });
  if (status === "empty") return <p>No people</p>;
  return (
    <ul>
      {table.getRowModel().rows.map((row) => (
        <li key={row.id}>
          {row.getAllCells().map((cell) => (
            <span key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())} </span>
          ))}
        </li>
      ))}
    </ul>
  );
}

The hook returns the TanStack Table instance, the status and the selection, and runs row reorder for markup of your own. The data table’s headless hook guide covers it in full.

Rules

  • A custom Row keeps a stable identity. Define slot components outside the component that renders the table, or memoize the components map. A component written inline in components={{ Row: (props) => <tr {...props} /> }} is a new type on every render, so React remounts every row: focus, a running drag and any state inside the row are lost.
  • Spread what an element part receives. Leaving out props drops the data-* state, the handlers or the accessible names.
  • Widget parts get told what is true, not what to render. A widget part decides its markup from the facts it receives (checked, pageCount, direction) and reports back through the callbacks it is given (onCheckedChange, nextPage), rather than reaching into the table.
  • Keep the accessible names. Widget parts receive their aria-label from the labels; pass it to the control that takes focus.