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 Button and Card); 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.