I've built design systems with CSS, Sass, Stylus, css-modules, styled-components, emotion, system-components and styled-system. I've used BEM, scoped CSS, atomic CSS, and even made up conventions.
But they never felt quite right—some were too laborious and too error-prone. Others were too slow and too opinionated.
I've lost count of how many times I've been asked to build complex components from scratch—dialogs, tooltips, dropdown menus, popovers, sheets, tabs.
But they were all flawed and lacked fundamental accessibility features. When working against a deadline, it's almost impossible to build them correctly.
So for the last couple of years, we at Modulz worked hard to facilitate how teams can build, maintain and scale their design systems—all whilst adhering to the WAI-ARIA design pattern.
If you've never heard of Stitches or Radix Primitives before, here's a little background.
Stitches is a styling solution focusing on component architecture and developer experience.
It introduces a first-class variant API, enabling design system authors to express their intent better. It's fully typed, catching potential mistakes and improving the scalability of design systems. It's lightweight, coming in at less than 5kb. And finally, it's a breeze to get up and running with it.
Radix Primitives is a low-level UI component library focusing on accessibility, customisation and developer experience. It's a comprehensive library of unstyled, and accessible components, with over twenty-five to choose from.
All its components adhere to the WAI-ARIA design patterns. They can be the base layer of your design system or adopted incrementally.
With the combination of Stitches and Radix Primitives, you can build a robust design system with little effort.
Ready? Let's get it.
Install Stitches with npm
or yarn
.
yarn add @stitches/react
Create a stitches.config.ts
(or .js
) file and import the createStitches
function.
// stitches.config.ts
import { createStitches } from '@stitches/react';
Destructure and export the styled
function.
// stitches.config.ts
import { createStitches } from '@stitches/react';
export const { styled } = createStitches();
The createStitches
function accepts a config. It's useful for defining a prefix, the default theme, media queries, custom utils, and other things. You can learn more about it here.
You can use the theme
config key to define the default theme of your system. A theme consists of scales. Scales consist of tokens.
Tokens are essential for constraint-based design.
Here's a simplified theme for the sake of brevity. For more complete themes, check out the Modulz design system config.
// stitches.config.ts
import { createStitches } from '@stitches/react';
export const { styled } = createStitches({
theme: {
colors: {
black: 'rgba(19, 19, 21, 1)',
white: 'rgba(255, 255, 255, 1)',
gray: 'rgba(128, 128, 128, 1)',
blue: 'rgba(3, 136, 252, 1)',
red: 'rgba(249, 16, 74, 1)',
yellow: 'rgba(255, 221, 0, 1)',
pink: 'rgba(232, 141, 163, 1)',
turq: 'rgba(0, 245, 196, 1)',
orange: 'rgba(255, 135, 31, 1)',
},
fonts: {
sans: 'Inter, sans-serif',
},
fontSizes: {
1: '12px',
2: '14px',
3: '16px',
4: '20px',
5: '24px',
6: '32px',
},
space: {
1: '4px',
2: '8px',
3: '16px',
4: '32px',
5: '64px',
6: '128px',
},
sizes: {
1: '4px',
2: '8px',
3: '16px',
4: '32px',
5: '64px',
6: '128px',
},
radii: {
1: '2px',
2: '4px',
3: '8px',
round: '9999px',
},
fontWeights: {},
lineHeights: {},
letterSpacings: {},
borderWidths: {},
borderStyles: {},
shadows: {},
zIndices: {},
transitions: {},
},
});
From this point on, tokens will be available when you write CSS. By default, Stitches maps CSS properties to theme scales. You can see the mapping here.
For example, here's a style object setting tokens as values.
{
backgroundColor: '$turq', // maps to theme.colors.turq
color: '$black', // maps to theme.colors.turq
fontSize: '$5', // maps to theme.fontSizes.5
padding: '$4', // maps to theme.space.4
borderRadius: '$3' // maps to theme.radii.3
}
Box
primitiveOne thing I like to do is create a low-level Box
component.
Import the styled
function to create your first component. Call the styled
function providing the element you want to style and the optional style object. I don't usually add any styles for Box
, so I left it out.
// box.tsx
import { styled } from '@stitches/react';
export const Box = styled('div');
It's useful because components built with Stitches support a css
prop, meaning you have access to tokens, utils, and media queries.
You can use the Box
, the lowest-level building block of your application.
import { Box } from './box';
const App = () => (
<Box
css={{
backgroundColor: '$turq',
color: '$black',
fontSize: '$5',
padding: '$4',
}}
>
Box
</Box>
);
The css
prop accepts a style object. Unlike the style
attribute, here, you can add pseudo-classes, descendant selectors, media queries, etc.
Additionally, you can access tokens via the $
prefix in the css
prop.
It's worth noting that components built with Stitches are also polymorphic. So you can use the as
prop to change the underlying element.
import { Box } from './box';
const App = () => (
<Box as="h1" css={{ color: '$turq' }}>
Box as h1
</Box>
);
You can use the styled
function to create as many components as you need. Here's a demo button.
// button.tsx
import { styled } from './stitches.config.ts';
export const Button = styled('button', {
borderRadius: '$round',
fontSize: '$4',
padding: '$2 $3',
border: '2px solid $turq',
color: '$white',
'&:hover': {
backgroundColor: '$turq',
color: '$black',
},
});
To use tokens, you need to prefix them with a
$
(dollar sign).
Then render it.
import { Button } from './button';
const App = () => <Button>My Button</Button>;
Stitches offer a first-class variant API. It's a powerful way to express alternative styles for a given component.
// button.tsx
import { styled } from './stitches.config.ts';
export const Button = styled('button', {
// styles
variants: {
color: {
turq: {
border: '2px solid $turq',
'&:hover': {
backgroundColor: '$turq',
color: '$black',
},
},
orange: {
border: '2px solid $orange',
'&:hover': {
backgroundColor: '$orange',
color: '$black',
},
},
},
},
});
You can apply variants as defined in your component. The key becomes a prop.
import { Button } from './button';
const App = () => (
<Box css={{ display: 'flex', gap: '$3' }}>
<Button color="turq">My Button</Button>
<Button color="orange">My Button</Button>
</Box>
);
We created a variant called color
that can be either gray
or purple
. You can use the defaultVariants
key to set it to gray
by default:
// button.tsx
import { styled } from './stitches.config.ts';
export const Button = styled('button', {
// styles
// variants
defaultVariants: {
color: 'turq',
},
});
You can also add styles based on a combination of variants via the compoundVariants
API. Learn more about compound variants here.
You can use the media
config key to define the media queries of your system. This is useful for applying responsive styles and for responsively applying variants.
// stitches.config.ts
import { createStitches } from '@stitches/react'
export { styled } = createStitches({
theme: {},
media: {
bp1: '(min-width: 575px)',
bp2: '(min-width: 750px)',
}
})
You can use your predefined media queries within the style object or when applying variants. To use media queries, prefix your media keys with an @
(at sign).
For example, the code below changes the backgroundColor
property over different media queries.
// responsive-box.tsx
import { styled } from './stitches.config.ts';
const ResponsiveBox = styled('div', {
backgroundColor: '$pink',
'@bp1': { backgroundColor: '$turq' },
'@bp2': { backgroundColor: '$orange' },
});
Media queries also work in the
css
prop.
Alternatively, you can organise the increments as variants
:
// responsive-box.tsx
import { styled } from './stitches.config.ts';
const ResponsiveBox = styled('div', {
variants: {
color: {
pink: { backgroundColor: '$pink' },
turq: { backgroundColor: '$turq' },
orange: { backgroundColor: '$orange' },
},
},
});
And then responsively apply them:
import { ResponsiveBox } from './responsiveBox';
function App() {
return (
<ResponsiveBox
color={{
'@initial': 'orange',
'@bp1': 'pink',
'@bp2': 'turq',
}}
/>
);
}
Use the
@initial
key to set the initial variant.
With the features above, you have enough tools to build a component-driven, constraint-based design system.
Install Radix Primitives with npm
or yarn
. You need to install them individually.
yarn add @radix-ui/react-dialog
yarn add @radix-ui/react-dropdown-menu
yarn add @radix-ui/react-tooltip
Most design systems will need a dialog component. Although they may seem like a straight forward component to build from scratch, there are many accessibility concerns to look out for.
For example, the focus should be automatically trapped within the dialog's content. The first interactive element should be focused by default. If there are no interactive elements, then the dialog's content should receive focus. When the dialog is closed, the focus must return to the dialog's trigger.
Pressing Space/Enter
while the dialog trigger's focused should open the dialog. Pressing Escape
should close the dialog.
The Radix dialog consists of the following parts:
import * as Dialog from '@radix-ui/react-dialog';
export default () => (
<Dialog.Root>
<Dialog.Trigger />
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Close />
</Dialog.Content>
</Dialog.Root>
);
I can style the parts above and re-export them as part of my design system. I have full control over the API and abstractions.
This is what I want the API of my design system's dialog to look like:
import { Dialog } from './dialog';
function App() {
return (
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>Dialog content goes here</DialogContent>
</Dialog>
);
}
Notice how in my API, I don't want to have to manually render the Dialog.Overlay
part. I am making a decision that I always want the overlay to appear.
To achieve my desired API, I'll start by importing all of the dialog parts. Some parts will be a simple re-export, and some will be styled.
// dialog.tsx
import { styled } from './stitches.config';
import { Cross1Icon } from '@radix-ui/react-icons';
import * as DialogPrimitive from '@radix-ui/react-dialog';
const StyledOverlay = styled(DialogPrimitive.Overlay, {
// overlay styles
});
export function Dialog({ children, ...props }) {
return (
<DialogPrimitive.Root {...props}>
<StyledOverlay />
{children}
</DialogPrimitive.Root>
);
}
const StyledContent = styled(DialogPrimitive.Content, {
// content styles
});
const StyledCloseButton = styled(DialogPrimitive.Close, {
// close button styles
});
export const DialogContent = React.forwardRef(({ children, ...props }, forwardedRef) => (
<StyledContent {...props} ref={forwardedRef}>
{children}
<StyledCloseButton>
<Cross1Icon />
</StyledCloseButton>
</StyledContent>
));
export const DialogTrigger = DialogPrimitive.Trigger;
Then render the custom dialog.
import { Dialog, DialogTrigger, DialogContent } from './dialog';
import { Button } from './button';
const App = () => (
<Dialog>
<DialogTrigger asChild><Button>Open dialog</Button</DialogTrigger>
<DialogContent>
<p>Order complete.</p>
<p>You'll receive a confirmation email in the next few minutes.</p>
</DialogContent>
</Dialog>
);
Notice how you can change the rendered element via the asChild
prop.
import { Dialog, DialogTrigger, DialogContent } from './dialog';
import { Button } from './button';
const App = () => (
<Dialog>
<DialogTrigger asChild>
<Button color="orange">Open dialog</Button>
</DialogTrigger>
<DialogContent>
<p>Order complete.</p>
<p>You'll receive a confirmation email in the next few minutes.</p>
</DialogContent>
</Dialog>
);
You can add animation to the Radix dialog by targeting the data-state
attribute.
But first, we need to export the keyframes
function from Stitches.
import { createStitches } from '@stitches/react';
export const { styles, keyframes } = createStitches(/* your config */);
Then use the keyframes
function to create CSS keyframes.
// dialog.tsx
import { keyframes } from './stitches.config';
const fadeIn = keyframes({
from: { opacity: 0 },
to: { opacity: 1 },
});
const fadeout = keyframes({
from: { opacity: 1 },
to: { opacity: 0 },
});
const StyledOverlay = styled(DialogPrimitive.Overlay, {
'&[data-state="open"]': {
animation: `${fadeIn} 300ms ease-out`,
},
'&[data-state="closed"]': {
animation: `${fadeout} 200ms ease-out`,
},
});
const StyledContent = styled(DialogPrimitive.Content, {
'&[data-state="open"]': {
animation: `${fadeIn} 300ms ease-out`,
},
'&[data-state="closed"]': {
animation: `${fadeout} 200ms ease-out`,
},
});
You can use CSS animation to animate both mount and unmount phases. The latter is possible because the Radix Primitives will suspend unmount while your animation plays out.
You can rely on the powerful variant API from Stitches to offer different types of animations.
For example, the Content
part can support two types of animation: a fade
or a scale
.
// dialog.tsx
import { keyframes } from './stitches.config';
const fadeIn = keyframes({…});
const fadeout = keyframes({…});
const scaleIn = keyframes({
from: { transform: 'scale(0.9)' },
to: { transform: 'scale(1)' },
})
const StyledContent = styled(DialogPrimitive.Content, {
variants: {
animation: {
fade: {
'&[data-state="open"]': {
animation: `${fadeIn} 300ms ease-out`,
},
'&[data-state="closed"]': {
animation: `${fadeout} 200ms ease-out`,
},
},
scale: {
animation: `${fadeIn} 300ms ease-out, ${scaleIn} 200ms ease-out`,
},
},
},
});
Then you can pick one:
import { Dialog, DialogTrigger, DialogContent } from './dialog';
import { Button } from './button';
const App = () => (
<Dialog>
<DialogTrigger asChild>
<Button>Open dialog</Button>
</DialogTrigger>
<DialogContent animation="scale">
<p>Order complete.</p>
<p>You'll receive a confirmation email in the next few minutes.</p>
</DialogContent>
</Dialog>
);
This process is similar to most Radix primitives. They're low-level enough to allow you to re-export them with your own API, yet high level enough that's an intuitive experience. It's all just components.
Currently, there are over 25 primitives available, with more coming almost every couple of weeks.
Learn more on radix-ui.com
With Stitches, you're able to create resilient, maintainable and scalable design systems. You can focus on what matters the most. The system itself — your theme, scales and tokens. The look and feel of the components and their potential variations. The way each component can look at different breakpoints.
With Radix, you're able to delegate the logic and accessibility concerns of complex components while having full control over their aesthetics and behaviour. Style its parts and states. Use them controlled or uncontrolled. Add animations, either with CSS or an animation library.
It's an exciting time to be building UIs and design systems.
If you're interested in learning more, here are some useful resources:
Thanks for reading ✌️
Shoutout to Jonathan Neal for the excellent work on Stitches.
Shoutout to Benoît Grélard and Jenna Smith for the outstanding work on Radix Primitives.
Shoutout to Paco Coursey, Mike San Román and Vlad Moroz for proofreading this article.
Share this post on Twitter