Browse docs
Browse docs
Compound components render multiple named slots (a Dialog has
overlay / content / title / body / closeButton; a DataGrid has
root / toolbar / search / table / thead / etc). The
theme.components.<Name>.slotProps block lets you set per-slot
className defaults application-wide — the equivalent of "chrome my
whole DS in one place".
For the mental model + precedence chain, see Component Defaults System.
Same 4-level precedence as defaults:
| Level | Source | Applies to |
|---|---|---|
| 1 | Slot's intrinsic styling (from the TV recipe) | content: 'fixed left-1/2 …' in dialog.variants.ts |
| 2 | theme.components.<Name>.slotProps | Consumer-wide DS chrome |
| 3 | Instance slotProps | Per-render override |
| 4 | Instance sx (root slot only) | Nuclear escape |
Higher levels win via tailwind-merge — Tailwind classes of the same
category (background beats background, spacing beats spacing) are
deduped and the last one wins.
Every compound component ships instance slotProps today. The pattern:
<Dialog
open={open}
onOpenChange={setOpen}
title="Confirm"
slotProps={{
content: { className: 'max-w-lg' },
title: { className: 'font-mono uppercase' },
body: { className: 'py-6' },
}}
>
Body content
</Dialog>Works today. Verified by 12+ instance-slotProps precedence tests in
the catalog (libs/dashforge/tw/src/components/*/[A-Z]*.precedence.test.tsx).
The theme.components.<Name>.slotProps field is declared in types AND
consumed by the following compound components (verified 2026-07-14):
Pattern per-component: theme slot className goes first, instance slot
className second, sx on the root last. tailwind-merge picks the
winning class when they collide.
Other components (form fields, calendar pickers, layout primitives)
still expose instance slotProps but the theme-level wire-in is a
follow-up — tracked in the Sprint 6 milestone.
The API surface once fully wired:
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Dialog: {
defaults: { size: 'md', showCloseButton: true },
slotProps: {
content: { className: 'shadow-2xl' },
title: { className: 'text-lg font-semibold' },
closeButton: { className: 'rounded-full' },
},
},
Drawer: {
defaults: { position: 'right', size: 'md' },
slotProps: {
header: { className: 'bg-neutral-50 border-b-2' },
body: { className: 'px-6 py-4' },
footer: { className: 'bg-neutral-50' },
closeButton: { className: 'hover:bg-neutral-100' },
},
},
DataGrid: {
defaults: { size: 'md', density: 'comfortable' },
slotProps: {
toolbar: { className: 'bg-neutral-100 px-4' },
headerRow: { className: 'bg-neutral-100' },
headerCell: { className: 'font-semibold' },
row: { className: 'hover:bg-neutral-50' },
cell: { className: 'py-3' },
},
},
Tabs: {
defaults: { variant: 'underline' },
slotProps: {
list: { className: 'bg-neutral-50' },
trigger: { className: 'px-6' },
},
},
Menu: {
defaults: { align: 'start' },
slotProps: {
content: { className: 'shadow-lg' },
item: { className: 'text-sm' },
},
},
Popover: {
defaults: { side: 'bottom' },
slotProps: {
content: { className: 'shadow-xl' },
},
},
Alert: {
defaults: { variant: 'soft' },
slotProps: {
icon: { className: 'shrink-0' },
content: { className: 'leading-relaxed' },
},
},
},
});| Component | Slot roster |
|---|---|
| Dialog | overlay / content / title / description / body / actions / closeButton |
| Drawer | overlay / content / header / title / body / footer / closeButton / resizeHandle |
| DataGrid | root / toolbar / search / scroll / table / thead / tbody / headerRow / headerCell / row / cell / emptyState / loadingOverlay / selectionCell / rowActionsCell / bulkActionFooter / pagination |
| Tabs | root / list / trigger / content |
| Popover | content / arrow |
| Alert | root / icon / content / action / closeButton |
| Menu | (no slotProps in types yet — instance slotProps N/A) |
| Card | (no slotProps in types — use sx on <CardContent> / <CardActionArea> sub-components) |
Each slot map is documented in the component's *SlotProps interface
export (e.g. import type { DialogSlotProps } from '@dashforge/tw').
Once theme-level slotProps ships, the pattern will look like this:
patchTheme({
components: {
Dialog: {
slotProps: {
title: { className: 'text-primary-600' },
},
},
},
});
// Bare — inherits the theme's title styling (text-primary-600).
<Dialog open={open} onOpenChange={setOpen} title="Info">Body</Dialog>
// Instance override — theme.title is REPLACED, not merged with the
// bare consumer's slotProps.title.
<Dialog
open={open}
onOpenChange={setOpen}
title="Info"
slotProps={{ title: { className: 'text-danger-600' } }}
>
Body
</Dialog>
// ↑ renders `text-danger-600` — instance wins.defaults sub-block.