Browse docs
Browse docs
@dashforge/[email protected] introduces a theme-level component defaults
layer — the theme.components.<Name>.defaults block on your TW theme.
This is the third layer of the customization stack, sitting between
token-level customization (colors, spacing, radii) and per-instance sx
/ prop overrides. It answers a specific class of consumer question:
"I want ALL Buttons in MY app to be
size='lg'by default. Do I really have to writesize='lg'on every instance?"
Before 1.2.0: yes (or fork the component). After 1.2.0: no — set it once in the theme.
┌───────────────────────────────────────────────────────────────┐
│ Layer 3 — Instance overrides <Button size="sm" sx="mt-2"> │
│ Layer 2 — Component defaults theme.components.Button │
│ Layer 1 — Design tokens theme.color / spacing / radii │
└───────────────────────────────────────────────────────────────┘Layer 1 — Tokens. Change radius.md and every component using
rounded-md updates. Documented in
Design Tokens.
Layer 2 — Component defaults (NEW in 1.2.0). Pin a component's
variant axes globally. Consumers see the DS identity without
per-instance boilerplate.
Layer 3 — Instance. A specific call site still wins. <Button size="xl"> renders xl even if the theme default is md.
Each layer is orthogonal — you can use one, two, or all three. Layer 2
is opt-in; consumers who don't set theme.components see identical
1.1.x behavior.
Pin a Button default application-wide:
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Button: {
defaults: {
variant: 'solid',
color: 'primary',
size: 'md',
},
},
},
});Now every bare <Button>text</Button> in your app renders as
variant="solid" color="primary" size="md". A specific site that
wants xl still writes <Button size="xl"> — the instance prop
overrides the theme default.
Four levels, lowest → highest:
| Level | Source | Example |
|---|---|---|
| 1 | Tailwind-variants defaultVariants | defaultVariants: { size: 'md' } in button.variants.ts |
| 2 | theme.components.<Name>.defaults | patchTheme({ components: { Button: { defaults: { size: 'lg' } } } }) |
| 3 | Instance props | <Button size="xl"> |
| 4 | sx prop | <Button size="xl" sx="!size-2xl"> (nuclear escape) |
Higher levels always beat lower ones. The chain is deterministic — no CSS specificity magic, no stylesheet-order surprises. Every component internally applies the same merge order:
// Inside Button.tsx
const themeDefaults = useComponentDefaults('Button');
const merged: ButtonProps = { ...themeDefaults?.defaults, ...props };
// ↑ theme first, then instance props spread over — instance winsThe sx prop is a separate concern layered ON TOP of the resolved
variant classes via tailwind-merge. When a Tailwind utility appears in
sx, it beats the variant class of the same category (background beats
background, spacing beats spacing) regardless of order.
Two axes are theme-configurable but very different in scope. Pick by what the change SHOULD affect:
| Change | Use tokens? | Use component defaults? |
|---|---|---|
| Brand color palette | ✅ theme.color.primary | ❌ |
Radius scale (all rounded-* classes) | ✅ theme.radius | ❌ |
Spacing scale (p-4, gap-2, …) | ✅ theme.spacing | ❌ |
| "All Buttons are solid by default" | ❌ | ✅ theme.components.Button.defaults |
| "All Chips are outlined by default" | ❌ | ✅ theme.components.Chip.defaults |
| "All Drawers default to right position" | ❌ | ✅ theme.components.Drawer.defaults |
| "All Alert titles are semibold" | ❌ | ⚠️ needs slotProps — see Slot Defaults Override |
Rule of thumb: if the change is about what things look like
(color, radius, spacing, typography), it's a token. If the change is
about which variant a component picks by default (solid vs
outlined, sm vs md, right vs left), it's a component default.
Tokens fan out invisibly to everything. Component defaults are named per-component and only affect that component.
Every component with visual variant axes. That's 46 components in the current catalog:
Button, IconButton, Chip, Badge, AvatarTextField, Select, NumberField, OTPField,
Textarea, Checkbox, Switch, RadioGroup, Autocomplete,
Slider, DatePicker, TimePicker, DateRangePicker,
DateTimePicker, CalendarBox, Stack, Grid, Container, Divider, CardAlert, Skeleton, Spinner, Snackbar, ProgressTooltipDialog, Popover, Drawer, ConfirmDialogTabs, Menu, Stepper, Breadcrumbs, Pagination,
LinkTable, DataGridTopBar, LeftNav, TypographyComponents without visual axes are skipped by design:
AspectRatio, VisuallyHidden, Accordion, AppShell. The
ratio / sr-only / discriminated-union / behavioral-state axes on
those primitives aren't identity choices a DS designer would pin.
Setting a default for them would be nonsensical.
Consumers who don't set theme.components see identical 1.1.x
behavior. The useComponentDefaults hook returns undefined when
the theme doesn't specify a config, and the merge spread degenerates
to just props.
47 .precedence.test.tsx files across the catalog verify this
baseline — every "Level 1 — TV defaults" test in every file proves
the no-config path renders identically to the previous release.
slotProps sub-tree.