Theming

The theme belongs to you - the package ships defaults, every part is replaceable, and the type system widens with your extensions. This guide covers the full ladder, from using the built-ins to owning every token.

The ladder

  1. Use the default - <ThemeProvider> with no props renders the built-in baseTheme (dark-schemed).
  2. Override values - build your theme with createTheme(base, overrides) and pass it via theme.
  3. Repoint component defaults - theme.defaults / the provider's defaults prop change what components fall back to.
  4. Extend the type system - declaration merging adds palette colors, scale keys, and whole scales.
  5. Customize single components - theme.components (see customization.md).

One theme, yours

ThemeProvider provides exactly one theme. The package ships a single complete default - baseTheme - and you derive every mode from it; switching (dark/light, or ten brand modes) is your state:

import { ThemeProvider } from '@soroush.tech/design-system/theme'
import { baseTheme, createTheme } from '@soroush.tech/design-system/theme'

const dark = createTheme(baseTheme, { name: 'dark' /* your palettes */ })
const light = createTheme(baseTheme, { name: 'light', colorScheme: 'light' /* your palettes */ })

const [isDark, setIsDark] = useState(true)
<ThemeProvider theme={isDark ? dark : light}>{app}</ThemeProvider>

Building themes with createTheme

createTheme(base, overrides) deep-merges sparse patches onto a base theme:

import { baseTheme, createTheme } from '@soroush.tech/design-system/theme'

export const brandDark = createTheme(baseTheme, {
  palette: { primary: { main: '#00ff88' } }, // sibling fields (light/dark/contrastText) survive
  radii: { md: '10px' },
  shadows: ['none'], // arrays replace wholesale, never merge
})

Merge rules: plain objects recurse; arrays (shadows, fontSizes) and functions replace wholesale; undefined values are ignored - keys are added or replaced, never removed. Neither argument is mutated.

Component defaults (theme.defaults)

Components carry visible literal fallbacks (size = 'md', variant = 'outside', ...) resolved through themeDefault(theme, key, fallback). The optional theme.defaults map overrides any of them globally - essential when your theme uses entirely different size or palette keys:

<ThemeProvider defaults={{ size: 'compact', color: 'brand', switchVariant: 'inside' }}>

The full key table lives in the README's Theming section. The built-in baseTheme carries no defaults - the literals apply until you say otherwise. For per-component defaults (only Buttons compact), use theme.components.X.defaultProps instead (customization.md).

Extending the type system

Every scale is an open interface owned by @soroush.tech/design-system/theme - that module (not Emotion, an internal implementation detail) is the augmentation surface, and it merges reliably across tsdown's chunked d.ts for every consumer:

import type { PaletteEntry, SizeEntry } from '@soroush.tech/design-system/theme'

declare module '@soroush.tech/design-system/theme' {
  interface ThemePalette {
    brand: PaletteEntry // new palette color
  }
  interface ThemeBackground {
    tertiary: string // new background token
  }
  interface ThemeSizes {
    xl: SizeEntry // new size step
  }
  interface Theme {
    elevations: Record<'low' | 'mid' | 'high', string> // whole new scale
  }
}

Component prop unions derive via keyof Theme['scale'], so color="brand", bg="tertiary", and size="xl" typecheck everywhere automatically. Then supply the values:

const brand = createTheme(baseTheme, {
  palette: { brand: { main: '#00ff88', light: '#66ffb2', dark: '#00b25f', contrastText: '#000' } },
  background: { tertiary: '#101418' },
  sizes: { xl: { paddingTop: 2, paddingBottom: 2, paddingLeft: 4, paddingRight: 4, fontSize: 2 } },
  elevations: { low: '0 1px 2px', mid: '0 2px 6px', high: '0 6px 18px' },
  defaults: { size: 'xl' }, // components may default to augmented keys
})

Two rules: new object-valued keys (like a PaletteEntry) must be supplied complete - components read .main/.contrastText at runtime; and TypeScript cannot verify at runtime that an augmented token was supplied - always pair an augmentation with the matching override.

Breakpoints and layout

theme.breakpoints and theme.layout hold the page's layout system: where it changes, how many columns it has, its margins and gutters, its side panels and the top bar. Everything responsive is CSS - no component measures the window in JavaScript - so a prerendered page lays out the same with JavaScript off. The design system ships these as tokens and building blocks (AppBar, Sidebar, Drawer, Grid, Flex); each app composes its own pages from them.

theme.breakpoints - window classes

Each key is a range: its value is where the range starts, and it holds until the next key. The theme writes the start because media queries are min-width. xs starts at 0, so it applies everywhere until sm overrides it.

breakpoints: { xs: '0px', sm: '600px', md: '840px', lg: '1200px', xl: '1600px' }
KeyRangeMaterial 3 window class
xs0-599pxcompact
sm600-839pxmedium
md840-1199pxexpanded
lg1200-1599pxlarge
xl1600px+extra-large

They are window classes, not devices: a class is about the space the window has. Responsive props use them by name - each key from its size up - or by position:

<View display={{ xs: 'none', md: 'block' }} />
<View p={[2, 3, 4]} />                         // xs, sm, md
<View p={{ xs: 2, cqSm: 4 }} />                // cq keys: container queries on the same sizes

For hand-written styles, theme.breakpoints carries the query helpers (also exported for baseTheme as up, down, only, between, not and containerQueries):

const Panel = styled.div(({ theme }) => ({
  padding: 16,
  [theme.breakpoints.down('sm')]: { padding: 8 }, // @media (width < 600px)
  [theme.breakpoints.only('md')]: { padding: 24 }, // @media (840px <= width < 1200px)
}))

Every form, the container queries, the units and the order blocks are written in are covered in the styled-system Breakpoints guide.

theme.layout - layout tokens per window class

xs is complete; each later class lists only what changes and is merged over the ones before it. With baseTheme:

ClassColumnsMargin / gutterContentStart panelEnd panelTop bar
xs416 / 16fluidhiddenhidden64
sm832 / 16fluidrail 72hidden64
md1224 / 24fluidrail 72overlay 36064
lg1224 / 24fixed 1200rail 72overlay 41264
xl1224 / 24fixed 1600inline 280inline 41264
  • Units - margin and gutter are theme.space keys, so they move with the spacing scale. Bar heights, panel widths and maxWidth take a space key or any CSS length.
  • Columns - 1 to 16. 12 is the default from md up.
  • Panels - start and end follow the writing direction (they swap in RTL). mode is inline (beside the content at width), collapsed (a rail at railWidth), overlay (over the content at width) or hidden. A panel first declared at a later class needs its mode, width and railWidth there.
  • Bars - top (and an optional bottom) heights; 0 removes an inherited bar.

These are tokens: the app places its own AppBar, Sidebar and Drawer with them, and the design studio draws them on its frames.

The layout prop - the page frame everywhere, the columns on a Grid

layout turns the content tokens into CSS per window class. On any View-based component - Flex included - it is the frame: the margin and the max width, and the element keeps its own display. Grid adds the columns on top of that frame:

<View layout>...</View>                                // the theme's frame
<Flex layout gap={3}>...</Flex>                        // the frame, a flex column inside
<Grid layout>...</Grid>                                // the frame and the theme's columns
<Grid layout gap={2}>...</Grid>                        // its own gutter: 16px at every class
<Grid layout gap={{ xs: 2, md: 4 }}>...</Grid>         // 16px, then 32px from md up
<Grid layout={{ lg: { columns: 16 } }}>...</Grid>      // 16 columns from lg up
<Grid layout={{ maxWidth: '1280px', md: { columns: 6 } }}>...</Grid> // both
  • The frame - the full width of its parent (width: 100%, border-box) with the margin as padding-inline; in fixed mode capped by max-width and centred. It spans its parent in any context - a block, a grid item, a flex column or row - so the frame owns its width: size it with maxWidth, not width.
  • Grid - grid-template-columns: repeat(N, minmax(0, 1fr)) and the gutter as column-gap.
  • The gutter is the grid's gap - its own gap or columnGap (columnGap wins), single, per class ({ md: 4 }) or as an array, sets column-gap at every class it covers; where it is not set, the theme's gutter applies. Rows take gap's row half or rowGap. On a Flex or a View, gap is plain flexbox spacing.
  • Children take span - how many columns of a grid an element takes, on any View-based component: span={6}, per class span={{ xs: 4, md: 6 }}, or span="full" for the whole row whatever the column count (grid-column: 1 / -1, in any grid).
  • A field in the value (maxWidth: '1280px') replaces the theme's at every class; whatever it does not set still follows the theme. An entry under a class name replaces it from that class up, mobile-first like every responsive prop, on top of the fields.
  • The layout owns the column tracks, inline padding and max width it writes: an explicit gridTemplateColumns, padding or maxWidth on the same element does not override them from the second class up. A grid with tracks of its own leaves layout off and sits inside a frame.

Layout: one way per intent

Generated code, agents and people write each layout intent one way:

IntentWriteNot
Content on the page frame, no columns<View layout>, or <Flex layout> for a flex stack or rowmaxWidth + mx="auto" + px, a custom frame component
A section on the theme's columns<Grid layout>hand-written tracks on a page section, Flex with widths
The app's layouttheme.layout, built with createThemelayout values written on every page
A page's (or a region's) own layout<LayoutProvider layout={...}> around the pagethe same layout value on each section, a nested theme
A section's own columns, margin or max widthlayout={{ md: { columns: 6 } }}, layout={{ maxWidth }}helpers that repeat a value per class
A section's own guttergap={2} / gap={{ xs: 2, md: 4 }} on the Grida gutter field, gap and columnGap both
A child taking N columnsspan={6} / span={{ xs: 4, md: 6 }}gridColumn="span 6"
A child taking the whole rowspan="full"grid-column: 1 / -1, style, column-count objects
Space between rows onlyrowGapmargins on the children
A grid with tracks of its own (auto-fill, ...)a plain Grid without layout, inside a framelayout plus gridTemplateColumns

The same page without the prop needs every value written out - and stops following the theme:

<Grid
  gridTemplateColumns={{
    xs: 'repeat(4, minmax(0, 1fr))',
    sm: 'repeat(8, minmax(0, 1fr))',
    md: 'repeat(12, minmax(0, 1fr))',
  }}
  columnGap={{ xs: 2, md: 3 }}
  px={{ xs: 2, sm: 4, md: 3 }}
>
  ...
</Grid>

Your own breakpoints and layout

Override values with createTheme, like any scale:

const theme = createTheme(baseTheme, {
  breakpoints: { md: '900px' },
  layout: { lg: { content: { maxWidth: '1280px' } } },
})

The helpers on theme.breakpoints are rebuilt from the merged sizes. createTheme merges, so it can change values and add a larger breakpoint after the existing ones, but not remove or rename one. To use your own names, declare them by augmenting BreakpointOverrides (true adds a name, false removes a default) and replace the scale and the layout on the base:

declare module '@soroush.tech/design-system/theme' {
  interface BreakpointOverrides {
    mobile: true
    tablet: true
    web: true
    xs: false
    sm: false
    md: false
    lg: false
    xl: false
  }
}

const theme = createTheme(
  {
    ...baseTheme,
    breakpoints: withBreakpointHelpers({ mobile: '0px', tablet: '768px', web: '1280px' }),
    layout: { mobile: { ...a complete entry... }, tablet: { ... }, web: { ... } },
  },
  {}
)

Responsive props, cq keys (cqTablet), the helpers and the layout keys all follow the new names.

The scale interfaces

ScaleKey in ThemeInterface
Palettetheme.paletteThemePalette
Text colorstheme.textThemeText
Backgroundstheme.backgroundThemeBackground
Borderstheme.borderThemeBorder
Spacetheme.spaceThemeSpace
Breakpointstheme.breakpointsThemeBreakpoints
Layouttheme.layoutThemeLayout
Sizestheme.sizesThemeSizes
Radiitheme.radiiThemeRadii
Border widthstheme.borderWidthsThemeBorderWidths
Fontstheme.fontsThemeFonts
Font weightstheme.fontWeightsThemeFontWeights
Line heightstheme.lineHeightsThemeLineHeights
Letter spacingstheme.letterSpacingsThemeLetterSpacings
Typographytheme.typographyThemeTypographyVariants
Avatar sizestheme.avatarThemeAvatarSizes
Icon sizestheme.iconThemeIconSizes
Switch tokenstheme.switchThemeSwitch
Shadow tokenstheme.shadowThemeShadow

The spacing scale

theme.space steps on an 8px unit, and every step is a whole pixel. The same steps serve padding, margin, gap / rowGap / columnGap, the layout margin and the grid's gutter:

Step00.1250.250.50.7511.251.51.752345678910
px012468101214162432404856647280

Below 4px, lines are border widths (theme.borderWidths). Use 0.125 and 0.25 only for a gap that should be a hairline - a 1px or 2px gap the background shows through, as in a grid of tiles on a coloured Grid: <Grid layout gap={0.125} bg="grid">. | Defaults | theme.defaults | ThemeDefaults | | Components | theme.components | ThemeComponents |

The contract is verified against the real published d.ts by type-tests/augmentation.ts (pnpm test:types), so it can't silently break in a release.