As someone who's always been striving for consistency, building delightful and accessible experiences, and trying to do all that faster than ever, the concept of ✨design systems✨ has always interested me. I believe in setting up clear standards for colors and fonts and establishing patterns to build reusable components as the key to building sustainable UIs that can withstand the test of time.
For the past few years, I've been working a lot on this blog, the interactive experiences showcased in my blog posts, and several other tiny projects that needed consistency in branding and components. The more I worked on them, the more I felt the need to stop copy-pasting code and colors between projects and needed my own set of UI pieces: my personal design system.
After pouring countless hours into this project and sharing my progress over the past several months (almost a year now actually!), I felt it was time to write a little return on experience to focus on all the things I've learned while building a design system on my own 😊. So in this blog post, I'll go through the component patterns I came up with, explain how I picked up my tokens and overall the design system thinking mentality I adopted to make this project (somewhat) successful.
Context: Why would I even choose to build my own design system?
Before jumping on the actual building part of this blog post, I first want to give a bit more context on why I chose to dedicate time to this project. Among the many reasons why this project came to life, you will mainly find:
- Branding: I'm trying very hard to be unique in an endless sea of developers' blogs/websites looking more or less the same. I want people to recognize my work from afar through my choice of colors, logo, components design, playfulness, and attention to detail.
- Consistency: Each piece composing this system should have a purpose. All components follow the same guidelines and are composed of more primitive elements/tokens.
- Fun and Learning: I learned a lot about component building, design system thinking, and myself while building this tiny library. It helped me develop some empathy and step back and think twice about the interface of a component, composability, abstraction, and scalability. Focusing on one piece of the system at a time and making that one component mine was tons of fun and very satisfying.
This project was not a necessity per se, but the more my blog/portfolio and brand were evolving, the more I was striving for these things, and the more not having a design system was slowing me down. I needed my own set of "Lego pieces" that I could rearrange/combine infinitely. Thus the idea of building a personal design system came to my mind:
A small scoped design system mainly composed of primitive components focused solely on personal branding and personal use.
Even though the scope of this design system feels small compared to the bigger ones we can get to work on in a work context, it was not necessarily less complex to build. In the following parts, I'll go through the challenges and decisions I've made along the way when working on this project.
Tokens
Tokens are the discrete elements of styles like color palette, spacing units, shadows, or typography that form the foundation of a design system. Breaking down my different projects into these most fundamental pieces was essential when I started working on my design system.
Color system
First, I wanted to define an efficient solid color system. I ended up opting for what I dubbed a "two-tier color variable system":
- The first layer is a series of variables representing the HSL (Hue, Saturation, Lightness) values of the different colors within the palettes like
--blue-10: '222, 89%, 90%'
or--red-60: 0, 95%, 40%
. - The second layer is more of a generic alias to the colors that will end up referenced by the components of the design system:
--brand: hsl(var(--blue-50))
or--foreground: hsla(var(--gray-05), 60%)
. In this layer, we use the colors defined in the first one and compose them or expand them.
This system worked for me for the following reasons:
- Components never end up referencing actual "colors" per se: the background color of the
Button
component is not--blue-10
but--brand
and the value of that variable may evolve through time from blue to purple or anything else. Thanks to this system, components are more resilient to change: want to change the brand color? All you need to do is update the value of the--brand
variable, and all the components referencing it will update accordingly. - It lets me compose my color tokens, like adding some opacity. I talked about all this in a dedicated blog post: The Power of Composition with CSS variables where I showcase a few of my color composition patterns.
- Building themes like light and dark mode easily: in light mode
--brand
might reference--blue-60
, in dark mode it will be--blue-20
.
To illustrate the steps I took to pick up colors, create a palette, and come up with tokens, I built the little animated slideshow ✨ below:
Other tokens
Colors variables were my main focus to get started. They are perhaps the most crucial set of tokens to start building a compelling visual language. Then came the necessity to define consistent spacing units:
Spacing tokens
1--space-0: 0px;2--space-1: 4px;3--space-2: 8px;4--space-3: 12px;5--space-4: 16px;6--space-5: 24px;7--space-6: 32px;8--space-7: 40px;9--space-8: 48px;10--space-9: 56px;11--space-10: 64px;12--space-11: 80px;13--space-12: 96px;
and font related tokens:
Typography tokens
1--font-size-1: 0.75rem;2--font-size-2: 0.875rem;3--font-size-3: 1rem;4--font-size-4: 1.125rem;5--font-size-5: 1.25rem;6--font-size-6: 1.5rem;7--font-size-7: 2rem;
and little things like border radii:
Radii tokens
1--border-radius-0: 4px;2--border-radius-1: 8px;3--border-radius-2: 16px;
Components reference these tokens directly as they are less likely to be changing significantly over time.
Lessons learned
As I iterated on the components and developed common patterns, I often had to go back to the drawing board and define new tokens, redefine/refine some other ones, or combine and delete some. This process was particularly tedious for me as:
- Unlike my experience working on a design system in a professional context, I do not have a designer working on this one. I could only rely on gut feeling or trial and error until it felt like I nailed it or defined something that looked great.
- I imposed a rule on myself: containing the number of tokens as much as possible. That was, at times, really hard as I needed to preserve a balance between the "complexity of my design system" and the level of consistency.
The tokens I've defined so far will most likely evolve in the future as I'm expanding the number of components or experimenting with new colors or new ways to define variables. I learned through this project to see them more as a malleable layer of a design system instead of a solid bedrock where everything sits on top.
Component patterns
As of today, my design system contains only simple components or primitives. All I need is a set of simple pieces that lets me build things faster, with consistency, while still allowing some wiggle room for creativity: like a Lego kit. Thus, I optimized this project to preserve a balance of:
- Good developer experience (DX). I want my components to be useful and help me work, experiment, and iterate faster.
- Beautiful and cohesive design/design language. Thus allowing components to be composed not just on the code side of things but also visually.
I'm dedicating this part to showcasing some patterns and tricks I've come up with to achieve these goals while also making the components of my design system easier to use and maintain. If you're into component DX and composition patterns, this section should scratch an itch ✨.
Variant driven components
I've always been a big fan of styled components and wanted them to be at the core of this design system. This time, however, I opted for something a bit more opinionated: @stitches/react.
Among the many reasons why I picked this one rather than a more widely adopted library are:
- The variant-driven approach. Stitches emphasize the use of variants. The set of variants a given component supports must be predefined, which means no dynamic props are allowed for styling. I'm a big believer in this pattern when working on a design system. It makes you really think about developer experience and the interface of your components. I did my best to keep the number of variants down and privilege composition and compound components which I will detail later in this article.
- The support for polymorphism. Stitches lets you override the tag of a component via a polymorphic
as
prop. I'll showcase some examples of that pattern below. - The advanced Typescript support. Styled components' variants come with types automatically. There's no extra work needed.
Sample component showcasing the main features of Stitches
1import { styled } from '@stitches/react';23const Block = styled('div', {4borderRadius: 8px;5height: '50px';6width: '100%';7display: 'flex';8justifyContent: 'center;9alignItems: 'center';1011variants: {12/* the appearance prop will be automatically typed as 'primary' | 'secondary' */13appearance: {14'primary': {15background: 'blue';16color: 'white';17},18'secondary': {19background: 'hotpink';20color: 'white';21}22}23}2425/* specifying a default variant will make the appearance prop optional */26defaultVariant: {27appearance: 'primary';28}29});303132const App = () => {33return (34<Block as="section" appearance="secondary">35Styled-components36</Block>37)38}
When it comes to writing actual styles, I wrote my fair share of spaghetti CSS throughout my career, and I did not want this project to end up the same way. Luckily, Stitches keeps my styled-components code in check whether it's pattern-wise (no dynamic props, only variants) or type-wise, and makes me avoid many of the pitfalls I fell into with other styled-components libraries. On top of that, I came up with some custom patterns/rules to further improve the readability and maintainability of my code.
One pattern that I kept getting back to while building my components was relying on local CSS variables to handle transitions, and hover/focus/active states.
Button component using local CSS variables
1import { styled } from '@stitches/react';23const StyledButton = styled('button', {4/* Initializing local variables first and assigning them default values */5background: 'var(--background, white)',6color: 'var(--color, black)',7boxShadow: 'var(--shadow, none)',8opacity: 'var(--opacity, 1)',9transform: 'scale(var(--button-scale, 1)) translateZ(0)',1011/* Main styles of the component */12padding: 'var(--space-3) var(--space-4)',13fontSize: 'var(--font-size-2)',14fontWeight: 'var(--font-weight-500)',15height: '44px',16width: 'max-content',17transition: 'background 0.2s, transform 0.2s, color 0.2s, box-shadow 0.3s',18borderRadius: 'var(--border-radius-1)',1920/* Update local variables based on state/variant */21'&:active': {22'--button-scale': 0.95,23},2425'&:disabled': {26'--background': 'var(--form-input-disabled)',27'--color': 'var(--typeface-tertiary)',28},2930'&:hover': {31'&:not(:disabled)': {32'--shadow': 'var(--shadow-hover)',33},34},35'&:focus-visible': {36'--shadow': 'var(--shadow-hover)',37},3839variants: {40variant: {41primary: {42'--background': 'var(--brand)',43'--color': 'var(--typeface-primary)',44},45secondary: {46'--background': 'var(--brand-transparent)',47'--color': 'var(--brand)',48},49},50},51});
You can see in the snippet above that:
- The local variables used in this component sit at the top. This is where I initialize them with default values.
- Then, I follow up with the main body of the CSS which contains all the main CSS properties.
- Then, any nested code, variants, selectors,
::before
, or::after
statements only reassign those CSS variables.
The resulting code is much easier to read and I am less afraid to experiment with more complex CSS code without feeling like I am giving up on maintainability.
Utility components
Since the objective of this design system was to enable faster work/experimentations, I came up with a set of utility components. These components range from:
Box
. The primordial component of the design system. It's mainly an empty shell that I use as an enhanceddiv
that supports the Stitchescss
prop. It's useful for quickly prototyping without having to edit multiple files.
Box component
1import { styled } from '@stitches/react';23const Box = styled('div', {});45/* Usage with `css` prop on the fly */67const App = () => {8return (9<Box10css={{11background: 'var(--brand-transparent)';12color: 'var(--typeface-primary)';13borderRadius: 'var(--border-radius-1)';14width: 100,15height: 100,16}}17/>18)19}
Flex
andGrid
. These are my layout utility components. They aim to quickly createflex
andgrid
CSS layouts. They come with predefined variants/props to help set some of their unique properties likealignItems
,justifyContent
,gap
, orcolumns
. These slowly became life savers in the codebases that use my design system. They allow me to build prototypes with complex layouts in no time.
1const App = () => {2return (3<>4<Flex5alignItems="center"6direction="column"7justifyContent="center"8gap="2"9>10<Box css={...} />11<Box css={...} />12</Flex>13<Grid gap="4" templateColumns="repeat(2, 1fr)">14<Box css={...} />15<Box css={...} />16<Box css={...} />17<Box css={...} />18</Grid>19</>20);21};
Text
. Keeping anything typography-related throughout any project I have undertaken has always been a challenge. Thus, to solve this issue, I created this utility component. It has dedicated variants for sizes, colors, weights, and neat little utility props liketruncate
or ✨gradient
✨ which have been lifesavers many times. I appreciate using this component daily and ended up composing many more specific typography components on top of it.
1const App = () => {2return (3<>4<Text outline size="6">5Almost before we knew it,6we had left the ground.7</Text>8<Text truncate>9Almost before we knew it,10we had left the ground.11</Text>12<Text13gradient14css={{15backgroundImage:16'linear-gradient(...)',17}}18size="6"19weight="4"20>21Almost before we knew it,22we had left the ground.23</Text>24</>25);26};
VisuallyHidden
. The CSS to visually hide an element is very hard to remember. So I created a component to not have to Google it every so often 😄. It helps me add additional text for assistive technologies to elements can have more context when needed.
Compound components
I love compound components. I even dedicated three different articles about them 😄 (that are a bit dated now). I believe coming up with a nice set of compound components can significantly improve the DX of a given component.
There were two use cases where I ended up opting for compound components:
- When, if not split into smaller related components, the prop interface would be overloaded.
- When the component could potentially be composed in many ways.
Among some of the components that ended up leveraging a compound components pattern are:
Radio
1<Radio.Group name="options" direction="vertical" onChange={...}>2<Radio.Item3id="option-1"4value="option1"5aria-label="Option 1"6label="Option 1"7/>8<Radio.Item9id="option-2"10value="option2"11aria-label="Option 2"12label="Option 2"13checked14/>15</Radio.Group>
Card
1<Card>2<Card.Header>Title of the card</Card.Header>3<Card.Body>Content of the card</Card.Body>4</Card>
Some of my compound components are more restrictive than others when it comes to the types of components that can be rendered within them as children. In the case of Card
, I chose flexibility since I didn't want to "gate" its use. For Radio
, however, I felt the need to prescribe how to use it, and for that, I built the following little utility:
isElementOfType utility function
1export function isElementOfType(element, ComponentType): element {2return element?.type?.displayName === ComponentType.displayName;3}
This function lets me filter the components rendered under Radio
based on the displayName
of the child:
Using isElementOfType to filter out invalid children
1import RadioItem from './RadioItem';23const RadioGroup = (props) => {4const { children, ... } = props;56const filteredChildren = React.Children.toArray(children).filter((child) =>7isElementOfType(child, RadioItem);8);910return (11<Flex gap={2} role="radiogroup">12{filteredChildren}13</Flex>14)15}
Polymorphism and composition
Using composition results in more abstract components that require fewer props than their primitive counterpart and have a more narrow use case. When done well, they can increase developer velocity and make a design system even easier to use.
Given the wide range of applications this design system could have, and how primitive its pieces are, I wanted to optimize for composition and extensibility from the start. Luckily for me, picking the @stiches/react
library proved to be a great choice due to its support for polymorphism through the as
prop.
The as
prop allows picking which tag a component renders. I expose it in many of my utility components, like Text
for example:
1// Renders a p tag2<Text as="p">Hello</Text>34// Renders an h1 tag5<Text as="h1">Hello</Text>
Not only these components can take any HTML tag in their as
prop, but I found many use cases where more specifying other components makes perfect sense:
1<Card>2{/* Card.Body inherits the style, the props and the type of Flex! */}3<Card.Body as={Flex} direction="column" gap="2">4...5</Card.Body>6</Card>
The code snippet above showcases theCard.Body
compound component rendered as a Flex
component. In this case, not only does Card.Body
inherits the styles, but it also inherits the props and the types! 🤯
It does not stop there! On top of allowing for polymorphism, my styled-components are also built to be composed:
Composed components originating from Text
1const DEFAULT_TAG = 'h1';23const Heading = () => {4// Remapping the size prop from Text to a new scale for Heading5const headingSize = {61: { '@initial': '4' },72: { '@initial': '5' },83: { '@initial': '6' },94: { '@initial': '7' },10};1112// Overriding some styles of Text based on the new size prop of Heading13const headingCSS = {141: {15fontWeight: 'var(--font-weight-600)',16lineHeight: '1.6818',17letterSpacing: '0px',18marginBottom: '1.45rem',19},202: {21fontWeight: 'var(--font-weight-600)',22lineHeight: '1.6818',23letterSpacing: '0px',24marginBottom: '1.45rem',25},263: {27fontWeight: 'var(--font-weight-600)',28lineHeight: '1.6818',29letterSpacing: '0px',30marginBottom: '1.45rem',31},324: {33fontWeight: 'var(--font-weight-600)',34lineHeight: '1.6818',35letterSpacing: '0px',36marginBottom: '1.45rem',37},38};3940return (41<Text42as={DEFAULT_TAG}43{...rest}44ref={ref}45size={headingSize[size]}46css={{47...merge(headingCSS[size], props.css),48}}49/>50);51};5253// Creating a more abstracted version of Heading54const H1 = (props) => <Heading {...props} as="h1" size="4" />;55const H2 = (props) => <Heading {...props} as="h2" size="3" />;56const H3 = (props) => <Heading {...props} as="h3" size="2" />;57const H4 = (props) => <Heading {...props} as="h4" size="1" />;
This allows me to create more abstracted and narrow focused components out of the primitives of the design system.
Make it shine!
The final look and feel of the whole system is, to my eyes, as essential as the DX. I built these pieces not only to build faster but also to build prettier. On top of the colors and the little details such as:
- The slight border around cards makes them stand out a bit more.
I sprinkled some subtle, yet delightful, micro-interactions inspired by some of the work of @aaroniker_me in my components:
Adding those little details made this project fun and kept me going. Using them on other projects and this blog brings me joy ✨.
Packaging and shipping
In this last part, I want to focus on the shipping aspect of a design system such as:
- Packaging patterns, and which one I ended up picking.
- File structure.
- Bundling and releasing.
Versioning
Should you build an individual library? Or have one package per component? These are valid questions when thinking about how your projects will consume your design system.
Since I optimized for simplicity throughout this project, I chose to have one package for my entire design system: @maximeheckel/design-system
. Thus, I'd only have to ever worry about versioning this one library. However, this came with one major pitfall: I now had to make my package tree shakable so importing one component of my design system would not result in a big increase in bundle size on my projects.
If you're curious about other versioning/packaging patterns along with their respective advantages and drawbacks I'd recommend checking out Design System versioning: single library or individual components? from @brad_frost. It's an excellent read, and it helped me through my decision process for the versioning of this project.
File structure
When it comes to file structures, I found a lot of inspiration in @JoshWComeau's proposal in one of his latest blog posts titled Delightful React File/Directory Structure. Some of his decisions made sense to me and I highly encourage reading it!
Bundling
For bundling, I picked up esbuild. I got to play with my fair share of bundlers throughout my career, but nothing comes close to the speed of esbuild. I can bundle my entire design system (excluding Typescript type generation) in barely a second. Without having much prior experience with esbuilt itself, I still managed to come up with a working configuration relatively fast:
My current esbuild config
1const esbuild = require('esbuild');2const packagejson = require('./package.json');3const { globPlugin } = require('esbuild-plugin-glob');45const sharedConfig = {6loader: {7'.tsx': 'tsx',8'.ts': 'tsx',9},10outbase: './src',11bundle: true,12minify: true,13jsxFactory: 'createElement',14jsxFragment: 'Fragment',15target: ['esnext'],16logLevel: 'debug',17external: [...Object.keys(packagejson.peerDependencies || {})],18};1920esbuild21.build({22...sharedConfig,23entryPoints: ['src/index.ts'],24outdir: 'dist/cjs',25format: 'cjs',26banner: {27js: "const { createElement, Fragment } = require('react');\n",28},29})30.catch(() => process.exit(1));3132esbuild33.build({34...sharedConfig,35entryPoints: [36'src/index.ts',37'src/components/**/index.tsx',38'src/lib/stitches.config.ts',39'src/lib/globalStyles.ts',40],41outdir: 'dist/esm',42splitting: true,43format: 'esm',44banner: {45js: "import { createElement, Fragment } from 'react';\n",46},47plugins: [globPlugin()],48})49.catch(() => process.exit(1));
Here are some of the main takeaways from this config:
- esbuild does not provide any JSX transform feature or plugin like Babel does. I had to define a
jsxFactory
(L13-14) andjsxFragment
option as a workaround. - On the same note, I also had to add the
react
import/require statements through thebanner
option. It is not the most elegant thing, but it's the only way I could make this package work. - I bundled this package in both ESM and CJS format.
- ESM supports tree-shaking, hence, why you'll see multiple
entryPoints
(L35-40) provided in this section of the config.
Thanks to this configuration, I had a way to generate a tree-shakable package for my design system in seconds. This allowed me to fix to biggest drawback of using a single package: no matter what you'll import from the design system, only what's imported will end up bundled in the consumer project.
1// This will make the project's bundle *slightly* heavier2import { Button } from '@maximeheckel/design-system';34// This will make the project's bundle *much* heavier5import { Button, Flex, Grid, Icon, Text } from '@maximeheckel/design-system';
Releasing
For the release process of this project, I opted for a semi-manual approach for now:
- Releases are triggered manually on Github via a repository dispatch event.
- I select the
branch
and the release type (major/minor/patch
) based on the versioning rules I established earlier. - A Github workflow then starts and will bump the version based on the selected release type and publish the package on NPM.
I will most certainly iterate on this whole process very soon:
- I still do not have a proper CI process for this project.
- I don't even have a Storybook where I can publish and compare different versions of my design system components. This is still on my TODO list.
- I would love to automate the release process even further using libraries like Semantic Release.
This will most likely deserve a standalone blog post 👀 as there's a lot to talk about on this subject alone. In the meantime, you can head out to the repository of this project to check out the current release workflow.
Conclusion
As of writing these words, this project is still a work in progress. The resulting package is already actively being used on this blog and my upcoming portfolio (which is yet another massive project I have in progress). There's, however, still a lot left to do before I could publish what I could consider a good v1.0
! Among the things left are:
- Migrating the rest of the components to
@maximeheckel/design-system
. - Providing more primitive components such as
Modal
orTabs
. - Including a couple of utility React hooks that I use in all my projects like
useDebounce
oruseKeyboardShortcut
. - More experimentations with little micro-interactions to provide the best experience to the people visiting my sites. (and that includes you 😄!)
- Coming up with a great CI process, to visually test my components and avoid regressions: stay tuned for a potential dedicated blog post for this one 👀.
- Build a dedicated project page for the design system on my portfolio.
Right now, the set of primitive and utility components I have available through my design system is already helping me work faster and build consistent experiences. For more complex components, I'd lean towards using Radix UI as a solid base rather than building them from scratch. Time will tell what UI pieces I will eventually need.
It would be an understatement to qualify this design system as a daunting task. I spent on/off a couple of months on it, and it was sometimes frustrating, especially when coming up with the right tokens, but I still had a lot of fun working on this project and the result is worth it! I now have a working personal design system that gives me all the tools and components to build consistent experiences, and I can't wait to see how it will evolve.
Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.
Have a wonderful day.
– Maxime
A deep dive into my experience building my own design system that documents my process of defining tokens, creating efficient components, and shipping them as a package.