Design System
Your design system. Your tokens. Your rules. A fully typed React component library where nothing is hardcoded - every color, scale, and default belongs to you, not the package.
Tired of forking a component library just to change a color? Every token here is designed to be overridden - theming isn't an afterthought, it's the whole point.
Install
npm i @soroush.tech/design-system
react and react-dom are peer dependencies. Emotion is an internal implementation detail - it ships as a regular dependency, not a peer, so you never install it yourself.
Use it with AI tools
An MCP server serves this library's component inventory, every component's props reference, and the live token contract, so an AI assistant writes against the real API instead of guessing:
claude mcp add --transport http soroush https://mcp.soroush.tech/mcp
It also runs locally over stdio (npx -y @soroush.tech/mcp). Claude Code users can
install the plugin instead, which adds a house-style skill and a UI review agent on top
of the same server:
claude plugin marketplace add soroush-tech/design-system
claude plugin install soroush-design-system@soroush-tech
Any other tool can read docs.soroush.tech/llms.txt.
See @soroush.tech/mcp for the full tool list.
Importing
Import components by subpath, styling primitives from the barrel:
import { Typography } from '@soroush.tech/design-system/Typography'
import { Flex } from '@soroush.tech/design-system/Flex'
Styling primitives (the engine) come from the barrel @soroush.tech/design-system - styled, css, keyframes, the Theme type, styled-system functions, and createShouldForwardProp. The barrel (src/index.ts) is the engine abstraction layer: to swap the CSS-in-JS engine, only that file changes.
import { styled, css, type Theme } from '@soroush.tech/design-system'
ThemeProvider and the theme-value hooks (useTheme, useDefaultProps, withTheme) live at their own subpath:
import { ThemeProvider, useTheme } from '@soroush.tech/design-system/theme'
Style-computation helpers (useStyle, withStyles, StylesConsumer) - resolving a StyleInput/StyleFactory into a CSSObject - live at their own subpath:
import { useStyle } from '@soroush.tech/design-system/style'
Raw engine primitives for building your own global styles - Global, globalStyles, and the SSR-only CacheProvider/styleCache pairing - live at their own subpath, not the barrel:
import { Global, globalStyles, CacheProvider, styleCache } from '@soroush.tech/design-system/engine'
Global (retyped against this package's Theme) applies app-wide CSS and should render inside ThemeProvider so it resolves against the active theme. globalStyles(theme) is the base reset this package owns - box-sizing, margin/table resets, and theme-driven body colors - compose it into your own Global styles array alongside your app's own concerns (font-family, webfont loading, and anything else app-specific stay entirely app policy).
SSR critical-CSS extraction (e.g. with @emotion/server) is a separate, opt-in concern most apps never need - that's what CacheProvider and its paired styleCache instance are for.
Component inventory
Layout & surfaces
| Component | Purpose |
|---|---|
View | Styled div primitive - the base building block |
Flex | View with flexbox defaults |
Grid | CSS grid container |
Paper | Elevated surface with background and shadow |
Card | Content container built on Paper |
Quote | Bordered quote / terminal-panel surface on View |
AppBar | Top application bar |
Drawer | Slide-in side panel |
Content & data display
| Component | Purpose |
|---|---|
Typography | Text with variant → element mapping - the reference implementation for all components |
Link | Anchor with theme-aware styling |
Icon | Icon renderer |
Image | Image with styled-system props |
Avatar | User/entity avatar |
Table | Compound data table - exports TableContainer, TableHead, TableBody, TableFooter, TableRow, TableCell, TablePagination, TablePaginationActions, TableSortLabel, and TableControl from @soroush.tech/design-system/Table |
Inputs & forms
| Component | Purpose |
|---|---|
Button, ButtonGroup, ToggleButton | Actions and toggles |
TextInput | Text field |
Checkbox, Radio, Switch | Selection controls |
NativeSelect | Platform <select> |
Select | Custom select built on Popover + MenuItem |
MenuItem | Option row for Select's listbox |
Form, FormControl, FormLabel, FormHelperText | Form composition and labeling |
Pagination | Page navigation control |
Feedback
| Component | Purpose |
|---|---|
CircularProgress, LinearProgress | Determinate/indeterminate progress indicators |
Skeleton | Loading placeholder with pulse/wave animations |
Backdrop | Dimmed overlay behind modal surfaces |
Overlay & behavior primitives
| Component | Purpose |
|---|---|
Portal | Renders children into a DOM node outside the parent hierarchy |
FocusTrap | Keeps focus inside a subtree |
Modal | Dialog primitive composing Portal, Backdrop, and FocusTrap |
Popover | Anchored floating surface built on Modal |
ThemeProvider | Supplies the theme and global styles to the app |
Theme tokens
Components never hardcode colors or sizes - props resolve against scales on the Theme object:
| Group | Scales |
|---|---|
| Color | palette (main/light/dark/contrastText per color) · text · background · border · colorScheme |
| Typography | typography (variant map) · fonts · fontSizes · fontWeights · lineHeights · letterSpacings |
| Space & shape | space · sizes · radii · borderWidths · shadows |
| Component scales | avatar (size steps) · skeleton (wave highlight) |
| Layering & effects | zOrder (appBar/drawer/modal) · blur · logoFilter · portraitBlend · portraitOpacity |
Prop types are derived from the interface (keyof Theme['text']), so adding a token to themes.ts propagates everywhere automatically. See design-system.md for the full scale table and the palette rules.
Theming
The theme belongs to you - the package only ships defaults. This section is the overview; the full guides are docs/theming.md and docs/customization.md.
One theme, yours
ThemeProvider provides exactly one theme - the built-in dark theme when you pass nothing. Bring one theme, or as many as you like: switching between them is your state, not the provider's.
import { ThemeProvider } from '@soroush.tech/design-system/theme'
import { baseTheme, createTheme } from '@soroush.tech/design-system/theme'
// Zero-config: the built-in `baseTheme`.
<ThemeProvider>{app}</ThemeProvider>
// Your theme - written from scratch or extended from the base.
const brand = createTheme(baseTheme, { palette: { primary: { main: '#00ff88' } } })
<ThemeProvider theme={brand}>{app}</ThemeProvider>
// Mode switching is app policy - own the state and pass the active theme:
const [isDark, setIsDark] = useState(true)
<ThemeProvider theme={isDark ? brandDark : brandLight}>{app}</ThemeProvider>
createTheme(base, overrides) (exported from @soroush.tech/design-system/theme) is the merge primitive: plain objects recurse, arrays (shadows, fontSizes) and functions replace wholesale, undefined values are ignored - keys are added or replaced, never removed.
Component defaults
Components carry visible literal fallbacks (size = 'md', variant = 'outside', ...) resolved through themeDefault(theme, key, fallback) - so every default, including behavioral variants, is overridable via the optional theme.defaults map or the provider's defaults prop:
| Key | Fallback | Drives |
|---|---|---|
size / compactSize | 'md' / 'sm' | sized components / dense table actions |
color / neutralColor | 'primary' / 'default' | accent controls / toggle controls |
textColor / accentTextColor | 'initial' / 'primary' | body text / icons + input labels |
bg / surfaceBg / inputBg | 'default'/'paper'/'terminal' | switch track / paper surfaces / inputs |
borderRadius / surfaceRadius | 'md' / 'sq' | grouped controls / surfaces |
avatarSize / borderColor / borderWidth | 'md' / 'primary' / 'thin' | avatars and rings |
iconSize | 'lg' | Icon default glyph size (theme.icon) |
buttonVariant / switchVariant | 'contained' / 'outside' | Button / Switch visual variants |
inputVariant | 'default' | TextInput, Select, NativeSelect frames |
avatarVariant / cardVariant | 'circular' / 'paper' | Avatar shape / Card treatment |
linkUnderline | 'always' | Link underline behavior |
paginationVariant / paginationShape | 'text' / 'circular' | Pagination items |
The built-in themes carry no defaults - the literals apply. A theme with entirely different size or palette keys stays valid by pointing the keys at its own tokens:
<ThemeProvider defaults={{ size: 'compact', color: 'brand', switchVariant: 'inside' }}>
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:
const brand = createTheme(baseTheme, {
components: {
Button: {
// Buttons default to sm + rounded; explicit props and ButtonGroup still win.
defaultProps: { size: 'sm', shape: 'rounded' },
// Per-slot CSS merged after Button's own styles - the theme wins the cascade,
// but per-instance props (m, p, width, ...) still beat the theme.
styleOverrides: {
root: ({ theme, ownerState }) => ({
letterSpacing: theme.letterSpacings.wide,
...(ownerState.variant === 'contained' && { textTransform: 'none' }),
}),
label: { fontStyle: 'italic' },
},
// A new variant value - register it first so it typechecks:
// declare module '@soroush.tech/design-system/theme' { interface ButtonVariants { dashed: true } }
variants: [
{
props: { variant: 'dashed' },
style: ({ theme }) => ({
backgroundColor: 'transparent',
border: `${theme.borderWidths.thin} dashed ${theme.border.primary}`,
}),
},
],
},
},
})
Style callbacks receive { theme, ownerState }, where ownerState is the styled root's resolved props (after group context, defaultProps, and theme.defaults). defaultProps sits in the standard precedence chain: explicit prop → group context → defaultProps → theme.defaults.* → the component's literal fallback. variants arrays are replaced wholesale by createTheme, never merged.
Your own components can join the same mechanism: create their roots with this package's styled(tag, { name: 'MyWidget', slot: 'root' }) and register the name by augmenting ThemeComponents. Zero-config themes pay nothing - the resolver bails out on the first check when theme.components is absent.
Extending tokens (declaration merging)
Every scale is an open interface declared on @soroush.tech/design-system/theme, so you can add palette colors, tokens, or whole scales - and every component prop union (color, bg, size, ...) widens automatically:
import type { PaletteEntry } 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 Theme {
elevations: Record<'low' | 'mid' | 'high', string> // whole new scale
}
}
Then supply the values with createTheme and use the new keys:
const brandDark = createTheme(baseTheme, {
palette: { brand: { main: '#00ff88', light: '#66ffb2', dark: '#00b25f', contrastText: '#000' } },
background: { tertiary: '#101418' },
elevations: { low: '0 1px 2px', mid: '0 2px 6px', high: '0 6px 18px' },
})
<ThemeProvider theme={brandDark}>
<Button color="brand" />
<View bg="tertiary" />
</ThemeProvider>
Notes: 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 actually supplied, so always pair an augmentation with the matching override.
Adding a component
Scaffold with the skill - it reads the live Typography files and design-system.md so output matches the codebase:
/new_theme_component ComponentName
Then work through the checklist at the bottom of design-system.md.
The library takes inspiration from Material UI - its component vocabulary and prop conventions will feel familiar - but it is written entirely in house. It is not a clone or a fork: every component is built from scratch on our own engine and token system, and the API is free to diverge wherever it serves its consumers better.
Release notes
Per-version notes for every published release live in
release-notes/.
Releases
@soroush.tech/[email protected]
Documentation only. No code and no API changes - the published bundle is identical to 1.3.3.
Added
- Every component now owns its documentation page:
<Name>.mdxsits beside the component'sREADME.mdand drives that component's page on docs.soroush.tech. The page renders the README's intro followed by every story in<Name>.stories.tsxas a live, editable demo. - The page is required - a component without one fails the docs build.
- The README now explains how to point an AI assistant at this library: the hosted MCP server, the same server locally over stdio, and the Claude Code plugin that wraps it.
docs/consumer/carries the consumer-facing setup guide, the layout-kit patterns, and copyabletheme.ts/providers.tsxstarters. The MCP server serves these to AI tools; they are repo files, not part of the npm tarball.
@soroush.tech/[email protected]
Maintenance release. One rendered string changes; no API changes.
Changed
TablePagination's default displayed-rows label now reads1-10 of 57with a hyphen, where it used an en dash.labelDisplayedRowsis a prop: pass your own to render it any other way.
No other component's rendered output changed.
@soroush.tech/[email protected]
Maintenance release - the package gained a Storybook of its own. No public API, behavior, or emitted-output changes.
Packaging
storybookno longer delegates to the site's Storybook: the package has its own config in.storybook/, serving only this package's stories against the shippedbaseThemeon port 6007 - the component library is now viewable without the site checked out.build:storybookandtest:storybookscripts were added alongside it; the latter runs every story's play function and a11y check.- New dev dependencies:
@storybook/addon-a11y^10.5.8,@storybook/addon-docs^10.5.8,@storybook/addon-vitest^10.5.8. - Dev-dependency bumps:
@storybook/react-viteandstorybook^10.5.7→^10.5.8,@testing-library/jest-dom^7.0.0→^7.0.1. jsdomstays on^29.1.1, held repo-wide inrenovate.json: jsdom 30 absolutises relative lengths ingetComputedStyle, so2remresolves to32pxand-0.025emto-0.4px, and everytoHaveStyleassertion written in rem or em fails.- No runtime, peer, or optional-peer dependency changes. The
files: ["dist"]allowlist is unchanged, so.storybook/is not part of the published tarball.
@soroush.tech/[email protected]
Restores the style props on the published component types - they resolved to any for consumers
on 1.2.0 and 1.3.0 - and refreshes dependencies.
(#335)
Fixed
- Style props are back on every component's published types.
TypographyProps,ImageProps,TextInputPropsand the rest inherit their space / layout / typography / flexbox / border / background / position / grid prop groups from@soroush.tech/styled-system, and the components used to reach for those types through this package's own barrel, which passes them on withexport *. The d.ts bundler flattens that barrel into adeclare namespace, which cannot carry anexport *, so the names were referenced but never declared in the emitted types. Under theskipLibCheckconsumers compile with, those dangling references degraded toanyrather than erroring: every style prop disappeared (<Typography mt={2}>,<Image width="100%">), andTextInput'sPick<LayoutProps, 'width' | 'minWidth' | 'maxWidth'>turned those three into required props. Components now import the types straight from@soroush.tech/styled-system, so nothing routes through that namespace. The emitted JavaScript and the runtime API are unchanged - this release moves types only.
Packaging
- Dev-dependency bumps:
@storybook/react-vite^10.5.6→^10.5.7,eslint-plugin-storybook^10.5.6→^10.5.7,storybook^10.5.6→^10.5.7,eslint^10.8.0→^10.8.1,@types/node^26.1.2→^26.2.0. - Repo-wide dependency overrides clear the vulnerable transitives the lockfile resolved for this
package's dev tree (
webpackforced to>=5,eslint-plugin-importto>=2.32.0, plus in-range patches forbrace-expansion,dompurify,js-yaml, andpostcss). - No runtime, peer, or optional-peer dependency changes.
@soroush.tech/[email protected]
Code-quality release driven by static analysis (#333 follow-up): no new components, no behavior changes.
Changed
TextInputpicks its input element (input/textarea/ auto-resizingtextarea) through a plain statement instead of a nested ternary. Rendered output is identical.
Breaking
ariaHidden(element, hide)is replaced byhideFromAria(element)andexposeToAria(element)on the Modal subpath. The boolean-selector helper was a leaked internal ofModalManager- no consumer in this repo imports it, and modal behavior is unchanged. Shipped in a minor deliberately, following the precedent of the styled-systemthemeGetroot-export removal: the export only existed by accident. MigrateariaHidden(el, true)→hideFromAria(el)andariaHidden(el, false)→exposeToAria(el).
@soroush.tech/[email protected]
Maintenance release - dependency refresh only. No new components, no API or behavior changes. (#280)
Packaging
reactandreact-dompeer floors raised^19.0.0→^19.2.8. Still React 19 only - the range narrows to the patch line this release is built and tested against. Consumers on an earlier React 19 should upgrade before installing.@soroush.tech/styled-systemdependency floor moves to^5.8.1with that package's own maintenance release.@emotion/*dependencies are unchanged.- Dev-dependency bumps:
@storybook/react-vite^10.5.0→^10.5.6,@testing-library/jest-dom^6.9.1→^7.0.0,@types/node^26.1.1→^26.1.2,@types/react^19.2.17→^19.2.18,@types/react-dom^19.2.3→^19.2.4,eslint^10.7.0→^10.8.0,eslint-plugin-storybook^10.5.0→^10.5.6,globals^17.7.0→^17.9.0,playwright^1.61.1→^1.62.1,react/react-dom^19.2.7→^19.2.8,storybook^10.5.0→^10.5.6,tsdown^0.22.7→^0.22.14. jsdomstays on^29.1.1. jsdom 30 absolutizes relative lengths ingetComputedStyle(2remresolves to32px), which breaks thetoHaveStyleassertions this package's tests write inrem/em; the upgrade is held inrenovate.jsonuntil those assertions stop comparing against computed style. Test-only - it does not affect the published package.
@soroush.tech/[email protected]
Features
Sidebar+SidebarItem- a collapsible vertical icon rail. Collapsed, it shows icon-only items; open, each item's label appears next to its icon, rendered away from the anchored edge (a right-anchored rail shows labels to the left of the icons), with the icon pinned so it holds its position across the transition. Purely controlled viaisOpen- the menu toggle lives with the consumer (typically the app bar). Children compose freely (items, logos, footers);anchor,expandedWidth,collapsedWidth, and anyFlexprop (bg, ...) shape the rail. Items are built onPressable- real buttons carrying only the rail's own styling, with no uppercase, bold weight, or letter-spacing inherited from the button family. They own their selected fill andaria-pressed, take asizedensity token, accept customchildrenfor their open-state content, and stay accessible collapsed througharia-labels. The item variant (textdefault,outlined,plain) can be set once on the rail -Sidebar'svariantflows to every item through context, with an item's ownvariantwinning. The rail is a labeled<nav>landmark, animates its width honoringprefers-reduced-motion, and registers theSidebarandSidebarItemtheme slots. WithhasPanel, the rail gains a second column and the selected item'schildrenrender there instead of inside its row - an icon rail beside a detail panel, sized bypanelWidth. The column appears only when the selected item has children, so nothing selected leaves no empty gap; it sits on the rail's inner side followinganchor, and is independent ofisOpen, so a collapsed icons-only rail can sit beside an open panel. The panel is a<section>named by the item'slabel, and that item becomes a disclosure carryingaria-expandedandaria-controls. Selection stays the consumer's - the rail only decides where the children go. Off by default, sochildrenkeep rendering inline in the row as before.Pressable- an unstyled clickable surface: button semantics (keyboard activation, focus ring, disabled state) with none of a button's looks. It carries no padding, margin, border, background, or font of its own, so it wraps arbitrary content without shifting it - the primitive to use instead of anonClickon adiv, which no keyboard or screen-reader user can reach. It renders adivby default, since a<button>may not legally contain a link, another button, or block-level markup; that div is givenrole="button", a tab stop,Enter/Spaceactivation (Space suppressed on keydown so the page never scrolls, fired on keyup), andaria-disabled.as="button"opts into a native button when the content is phrasing-only,hrefrenders an anchor, and both skip the shim.feedbackpicks what happens while it is held:none(default) leaves the content alone,opacityfades it toactiveOpacity, andhighlighttints the surface withcolorat 12.5% opacity. Feedback is press-only, so hover styling stays with the consumer. Its text is never selectable, so a drag or double-click presses rather than highlighting, as on a native button. Accepts thespace,layout,border, andtypographyprops, and registers thePressabletheme slot with themeablefeedback/color/activeOpacitydefaults.Icon- three new registry entries:folder,history, andedit_note.
Changes
gap,rowGap,columnGapandaspectRationow come fromViewfor every primitive built on it, resolved by@soroush.tech/styled-system5.8.0's ownspaceandlayoutparsers instead of per-componentsystem()mappings.Flex,Grid,Button,LinkandPaperdropped that hand-wiring - same CSS, same theme scale, same DOM filtering, and the props now accept responsive arrays everywhere.Flex's andGrid'sGapTokenexports are unchanged. (#314)
Breaking
Link'sgapnarrows fromnumber | stringto thetheme.spacetoken union, matching every other component'sgap. Token values (gap={2},gap="auto") are unaffected; raw CSS values (e.g.gap="8px") no longer type-check. Shipped in a minor deliberately: the prop reached raw CSS only becauseLinkhand-wired its owngapparser, which is the inconsistency this release removes, and no consumer in this repo passes one. Replace a raw value with the nearesttheme.spacetoken, or setgapthroughstyle/cssif you need an off-scale length. (#314)
@soroush.tech/[email protected]
First stable release (#303) - the soroush.tech design system as a standalone
package: design tokens, consumer-owned theming, and the full component set,
styled through its own engine. The headless markdown editor/renderer ships
separately as its companion package, @soroush.tech/[email protected].
Features
- A token-driven, consumer-extensible
baseThemewith the full component set: layout & surfaces (View, Flex, Grid, Paper, Card, AppBar, Drawer), content & data display (Typography, Link, Image, Icon, Table, Quote, Avatar, Skeleton, Pagination), inputs & forms (Button, ButtonGroup, TextInput, NativeSelect, Select, MenuItem, Checkbox, Radio, Switch, ToggleButton, Form, FormControl, FormLabel, FormHelperText), feedback (CircularProgress, LinearProgress), and overlay primitives (Modal, Popover, Portal, Backdrop, FocusTrap) - each on its own subpath (@soroush.tech/design-system/Button,.../Table/TableCell, ...). - A self-owned styling API:
styled,css, andkeyframesfrom the root barrel against the package's ownThemetype - the styling engine is an internal implementation detail, and nothing engine-specific leaks through the public surface. docs - Three settled entry points:
.../theme(ThemeProvider,createTheme,baseTheme,useTheme,useDefaultProps,withTheme), the custom-CSS layer at.../style(useStyle,withStyles,StylesConsumer), and raw engine escape hatches at.../engine(Global,globalStyles,CacheProvider,styleCachefor SSR critical-CSS extraction - plus the re-exported@soroush.tech/styled-systemprimitives). - Consumer-owned theming:
ThemeProviderprovides one theme (built-in dark by default);createTheme(base, overrides)deep-merges sparse patches; every scale is an open interface, extended viadeclare module '@soroush.tech/design-system/theme'- palettes, tokens, whole scales, andtheme.componentsslots. docs - Overridable component defaults: literal fallbacks (
size = 'md',variant = 'outside', ...) resolve throughtheme.defaults/ the provider'sdefaultsprop. - Per-component customization via
theme.components-defaultProps, per-slotstyleOverrideswith{ theme, ownerState }callbacks, and theme-contributedvariants(Button is the reference implementation; augmentableButtonVariants). Zero cost for themes that don't use it. - Test utilities under
.../utils/test(renderWithTheme, stories helpers) for consumers testing themed components.
Packaging
- Dual ESM/CJS build via tsdown with a subpath export for every component folder, including nested ones.
- Peers are just
react/react-dom^19; everything else ships as a regular dependency - nothing to install alongside. - Supersedes the
0.1.0preview release: the markdown suite (andCodeBlock) now live in@soroush.tech/markdown, and the former.../themes/.../colorssubpaths are folded into.../theme.