The slot model
هذه الصفحة غير مترجمة بعد، وهي معروضة بالإنجليزية.
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 Nasser | Analyst | Growth | |
| Chloe Martin | Engineer | Payments | |
| Diego Alvarez | Engineer | Growth | |
| Hana Kim | Manager | Care | |
| Jonas Weber | Support | Care | |
| 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
<Root><Table><Head><Body><HeaderRow><HeaderCell><Foot><FooterRow><FooterCell><Row><Cell>{Checkbox}{SortTrigger}{SortIcon}{ExpandToggle}with getSubRows (tree rows){Skeleton}while loading{Empty}when there are no rows{Error}when error is set{Pagination}{PaginationButton}{PageSizeSelect}<DragHandle>with enableRowReorder{CellEditor}<FillHandle>
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-statusisloading,error,emptyorready;data-size,data-striped,data-virtualfor the virtual table, andaria-busywhile loading.<HeaderCell>:data-sortedisascordescon the sorted column, besidearia-sort;data-alignfollows the column’smeta.align.<Row>: body rows carrydata-state="selected",data-depth,data-expanded(trueorfalseon rows that can expand),data-clickablewithonRowClick, anddata-row-id. During a drag they adddata-dragging,data-dragging-child,data-drop-positionanddata-drop-edge. The status rows carrydata-state="loading","empty"or"error".<Cell>:data-align, anddata-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 Park | Engineer | Platform | |
| Omar Haddad | Designer | Product | |
| Sofia Reyes | Engineer | Payments |
Set on this table: square.
| Lena Park | Engineer | Platform | |
| Omar Haddad | Designer | Product | |
| Sofia Reyes | Engineer | Payments |
import { useState } from "react";
import { DataTable, type CheckboxSlotProps } from "slotsmith/data-table";
import { DatePicker, type DpIconProps, type ISODate } from "slotsmith/date-picker";
import { SlotsmithProvider, type SlotsmithComponents } from "slotsmith/provider";
import { columns, people } from "../shared/people";
// A widget part of the app's own: a checkbox drawn as a button.
function Check({ radius, checked, indeterminate, disabled, onCheckedChange, "aria-label": label }: CheckboxSlotProps & { radius: string }) {
return (
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={label}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
style={{ inlineSize: "1.5rem", blockSize: "1.5rem", padding: 0, border: "1.5px solid currentColor", borderRadius: radius, background: "transparent", color: "inherit", lineHeight: 1, cursor: "pointer" }}
>
{indeterminate ? "–" : checked ? "✓" : ""}
</button>
);
}
const RoundCheckbox = (props: CheckboxSlotProps) => <Check {...props} radius="50%" />;
const SquareCheckbox = (props: CheckboxSlotProps) => <Check {...props} radius="4px" />;
// The date picker's icon: a diamond that fills while the calendar is open.
const DiamondIcon = ({ open }: DpIconProps) => (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill={open ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2">
<path d="M12 3 21 12 12 21 3 12Z" />
</svg>
);
// One map per component, defined outside any component so it keeps its identity.
const components: SlotsmithComponents = {
dataTable: { Checkbox: RoundCheckbox },
datePicker: { Icon: DiamondIcon },
};
// The second table's own prop: it wins over the provider for this slot only.
const squareCheckbox = { Checkbox: SquareCheckbox };
// Three people and three columns keep the two tables short.
const team = people.slice(0, 3);
const teamColumns = columns.slice(0, 3);
export default function ProviderComponents() {
const [day, setDay] = useState<ISODate | null>(null);
return (
<SlotsmithProvider components={components}>
<div style={{ display: "grid", gap: "0.75rem" }}>
<div style={{ maxInlineSize: "18rem" }}>
<DatePicker value={day} onChange={setDay} placeholder="Pick a date" aria-label="Start date" />
</div>
<p style={{ margin: 0 }}>Set on the provider: round.</p>
<DataTable data={team} columns={teamColumns} enableRowSelection hidePagination />
<p style={{ margin: 0 }}>Set on this table: square.</p>
<DataTable data={team} columns={teamColumns} enableRowSelection hidePagination components={squareCheckbox} />
</div>
</SlotsmithProvider>
);
}import type { DataTableColumnDef } from "slotsmith/data-table";
export type Person = { id: string; name: string; role: string; team: string; status: string; joined: string };
export const people: Person[] = [
{ id: "1", name: "Lena Park", role: "Engineer", team: "Platform", status: "Active", joined: "2021-03-08" },
{ id: "2", name: "Omar Haddad", role: "Designer", team: "Product", status: "Active", joined: "2020-11-16" },
{ id: "3", name: "Sofia Reyes", role: "Engineer", team: "Payments", status: "Away", joined: "2022-01-24" },
{ id: "4", name: "Yuki Tanaka", role: "Manager", team: "Platform", status: "Active", joined: "2019-06-03" },
{ id: "5", name: "Amir Nasser", role: "Analyst", team: "Growth", status: "Active", joined: "2023-02-13" },
{ id: "6", name: "Chloe Martin", role: "Engineer", team: "Payments", status: "On leave", joined: "2021-09-27" },
{ id: "7", name: "Jonas Weber", role: "Support", team: "Care", status: "Active", joined: "2022-07-11" },
{ id: "8", name: "Priya Nair", role: "Designer", team: "Product", status: "Away", joined: "2020-04-20" },
{ id: "9", name: "Diego Alvarez", role: "Engineer", team: "Growth", status: "Active", joined: "2023-05-30" },
{ id: "10", name: "Hana Kim", role: "Manager", team: "Care", status: "Active", joined: "2018-10-01" },
{ id: "11", name: "Tomas Novak", role: "Analyst", team: "Payments", status: "On leave", joined: "2022-12-05" },
{ id: "12", name: "Nadia Farouk", role: "Support", team: "Care", status: "Active", joined: "2024-01-15" },
];
export const columns: DataTableColumnDef<Person>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "team", header: "Team" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "joined", header: "Joined" },
];Each slot resolves on its own, in three layers, and the later one wins:
- The built-in fallback.
- The
componentsof the providers above, for that component. - The component’s own
componentsprop.
- 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,VirtualAutocompleteandVirtualFileUploaderuse 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 slotsmithAutocomplete, so it renders with the provider’sautocompleteslots. APageSizeSelectslot, fromdataTableor 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.
slotPropsstay 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 Park | Engineer | Platform | Active | 2021-03-08 | |
| Omar Haddad | Designer | Product | Active | 2020-11-16 | |
| Sofia Reyes | Engineer | Payments | Away | 2022-01-24 | |
| Yuki Tanaka | Manager | Platform | Active | 2019-06-03 |
0 of 12 selected
import { DataTable, useDataTableContext } from "slotsmith/data-table";
import { columns, people } from "../shared/people";
// Reads the same model as the table, from inside the provider.
function Summary() {
const { selection, table } = useDataTableContext();
return (
<p>
{selection.length} of {table.getCoreRowModel().rows.length} selected
</p>
);
}
// The default layout is Root > Table + Pagination. Here the pagination sits on
// top and a summary line follows the table, all inside the root.
export default function CompoundParts() {
return (
<DataTable.Provider data={people} columns={columns} enableRowSelection defaultPagination={{ pageIndex: 0, pageSize: 4 }}>
<DataTable.Root>
<DataTable.Pagination />
<DataTable.Table />
<Summary />
</DataTable.Root>
</DataTable.Provider>
);
}import type { DataTableColumnDef } from "slotsmith/data-table";
export type Person = { id: string; name: string; role: string; team: string; status: string; joined: string };
export const people: Person[] = [
{ id: "1", name: "Lena Park", role: "Engineer", team: "Platform", status: "Active", joined: "2021-03-08" },
{ id: "2", name: "Omar Haddad", role: "Designer", team: "Product", status: "Active", joined: "2020-11-16" },
{ id: "3", name: "Sofia Reyes", role: "Engineer", team: "Payments", status: "Away", joined: "2022-01-24" },
{ id: "4", name: "Yuki Tanaka", role: "Manager", team: "Platform", status: "Active", joined: "2019-06-03" },
{ id: "5", name: "Amir Nasser", role: "Analyst", team: "Growth", status: "Active", joined: "2023-02-13" },
{ id: "6", name: "Chloe Martin", role: "Engineer", team: "Payments", status: "On leave", joined: "2021-09-27" },
{ id: "7", name: "Jonas Weber", role: "Support", team: "Care", status: "Active", joined: "2022-07-11" },
{ id: "8", name: "Priya Nair", role: "Designer", team: "Product", status: "Away", joined: "2020-04-20" },
{ id: "9", name: "Diego Alvarez", role: "Engineer", team: "Growth", status: "Active", joined: "2023-05-30" },
{ id: "10", name: "Hana Kim", role: "Manager", team: "Care", status: "Active", joined: "2018-10-01" },
{ id: "11", name: "Tomas Novak", role: "Analyst", team: "Payments", status: "On leave", joined: "2022-12-05" },
{ id: "12", name: "Nadia Farouk", role: "Support", team: "Care", status: "Active", joined: "2024-01-15" },
];
export const columns: DataTableColumnDef<Person>[] = [
{ accessorKey: "name", header: "Name" },
{ accessorKey: "role", header: "Role" },
{ accessorKey: "team", header: "Team" },
{ accessorKey: "status", header: "Status" },
{ accessorKey: "joined", header: "Joined" },
];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
Rowkeeps a stable identity. Define slot components outside the component that renders the table, or memoize thecomponentsmap. A component written inline incomponents={{ 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-labelfrom the labels; pass it to the control that takes focus.