Per-component customization (theme.components)
Customize one component for the whole app - default prop values, per-slot CSS, and new variant values - without wrapping or forking. Zero-config themes pay nothing: the resolver bails out on its first check when theme.components is absent.
const brand = createTheme(baseTheme, {
components: {
Button: {
defaultProps: { size: 'sm', shape: 'rounded' },
styleOverrides: {
root: ({ theme, ownerState }) => ({
letterSpacing: theme.letterSpacings.wide,
...(ownerState.variant === 'contained' && { textTransform: 'none' }),
}),
label: { fontStyle: 'italic' },
},
variants: [
{
props: { variant: 'dashed' },
style: ({ theme }) => ({
backgroundColor: 'transparent',
border: `${theme.borderWidths.thin} dashed ${theme.border.primary}`,
}),
},
],
},
},
})
The Theme/Customization Storybook story renders all three mechanisms under a customized theme and is locked by Chromatic - it is the living contract.
Every styled element is registered - the ThemeComponents interface in src/theme/themes.ts is the authoritative key/slot list, so styleOverrides reaches every root and every sub-element slot (Switch's track/thumb/input, Select's listbox, LinearProgress's bars, ...), including the layout primitives View/Flex/Grid. Two cautions: an override on View/Flex/Grid cascades into the internals of every composed component (use those keys for app-wide policy only), and hidden/structural slots (input, valueGhost, positioner) carry accessibility or layout behavior - restyle their looks, not their geometry/visibility. Note that defaultProps is honored only by components that resolve props through useDefaultProps (currently Breadcrumbs, Button, Card, Pressable, Rating, NumberInput, RangeInput, TransferList, Collapse, FabMenu, ScrollArea, Autocomplete, Tooltip, Snackbar, Fab, Alert, Badge, Chip, Accordion, AccordionSummary, AccordionGroup, Tabs, Tab, TabScrollButton, TabPanel, Menu, ContextMenu, MenuList and MenuItem); everywhere else, use styleOverrides/variants.
defaultProps
Per-component default prop values. They sit in the standard resolution chain - later steps only apply when earlier ones are unset:
explicit prop → group context (e.g. ButtonGroup) → theme.components.X.defaultProps → theme.defaults.* → literal fallback
Use theme.defaults for global policy ("everything compact") and defaultProps for component policy ("only Buttons compact"). Components read them via the useDefaultProps(name) hook.
styleOverrides
Per-slot CSS merged after the component's own styles - the theme wins the cascade - but before the styled-system prop parsers, so per-instance props (m, p, width, ...) always beat the theme.
Values are plain CSS objects or callbacks receiving { theme, ownerState }. ownerState is the styled root's resolved props - after group context, defaultProps, and theme.defaults have been applied - so a conditional like ownerState.variant === 'contained' sees the same value the component's own styles used. Prop-keyed conditionals belong in the callback; there are no magic override keys to memorize.
Slot names (root, label, icon, ...) are typed per component in ThemeComponents and are public API - renaming one is a breaking change.
variants
Extra variant entries matched by props: an entry applies when every key in its props equals the render prop. Matching runs on the component's root slot only. Note that createTheme replaces variants arrays wholesale - composing two override sets does not concatenate them.
New variant values need two things - the type registration and the styles:
declare module '@soroush.tech/design-system/theme' {
interface ButtonVariants {
dashed: true // widens ButtonVariant so variant="dashed" typechecks
}
}
Registering the value without a matching variants entry renders an unstyled button - always pair them.
Slot props - customizing composed sub-elements
Some slots aren't raw CSS surfaces but composed components with their own token props - Card's title is a Typography with variant/color/fontFamily. CSS overrides are the wrong tool for "make every card title a subtitle instead of an overline"; that's a prop change. Such components accept slot prop objects in defaultProps, merged under any per-instance props:
components: {
Card: {
defaultProps: {
titleProps: { variant: 'subtitle1', color: 'secondary', fontFamily: 'body' },
captionProps: { mb: 2 },
},
// ...and the same slots are still CSS-addressable:
styleOverrides: {
title: { letterSpacing: '0.1em' },
},
},
}
Resolution per slot prop: component literal → theme defaultProps.titleProps → per-instance titleProps (instance wins). The slot-prop shapes are token-typed (CardSlotTypographyProps), so variant/color/mb autocomplete against your theme scales - including augmented keys.
So a composed slot is customizable at three levels: props (defaultProps.titleProps), CSS (styleOverrides.title), and structure (Card's title prop accepts a ReactNode for full replacement).
Registering your own components
The mechanism is infrastructure, not just configuration. Create your roots with this package's styled and register the name:
import { styled } from '@soroush.tech/design-system'
import type { ComponentConfig } from '@soroush.tech/design-system/theme'
declare module '@soroush.tech/design-system/theme' {
interface ThemeComponents {
MyWidget?: ComponentConfig<{ tone?: 'calm' | 'loud' }, 'root' | 'handle'>
}
}
const WidgetRoot = styled('div', { name: 'MyWidget' })({ padding: 8 })
const WidgetHandle = styled('span', { name: 'MyWidget', slot: 'handle' })({ cursor: 'grab' })
Your widget now honors theme.components.MyWidget.styleOverrides / .variants exactly like the built-ins.
Converting a design-system component (maintainers)
Button is the reference implementation. The rules:
- Pass
nameon the root andsloton named sub-elements. Slot names become public API. - Move styled-system parsers (
space,layout, ...) from the style arguments into thesystemPropsoption - this is what keeps instance props beating theme overrides. - Resolve
defaultPropsviauseDefaultProps(name)in the wrapper, in the chain above. - Type the component's
ThemeComponentsentry (ComponentConfig<OwnerState, Slots>); if it has variants, expose them via an augmentable interface (seeButtonVariants). - Cover all three mechanisms plus the precedence pairs in the component's tests.
Runtime cost
- Unused (
theme.componentsabsent): one no-op function call and a property lookup per named root per render; anonymous roots are raw Emotion. Bundle cost of the wrapper: well under a kilobyte. - Used: each overridden component behaves as if it shipped one more style function - the same cost class as its existing
sizeVariants/colorStylefunctions. Emotion caches by serialized output, so unchanged overrides reuse their class after the first render.