Why We Stopped Building Our Own Design System From Scratch
Introduction
Convincing a company to start building a design system from scratch is no easy task. Even though countless articles highlight the benefits, getting the green light isn’t as simple as following best practices. You have to put in the work—researching, justifying the investment, and persuading your team, product managers, and leadership that it's worth the effort.
I have a lot of respect for the people who initiated our current design system, and I completely agree with the original vision:
-
For designers, it enables creativity, while ensuring brand consistency.
-
For developers, it facilitates reusability, standardization, and a smooth workflow between design and code.
-
For product managers, it speeds up time to market.
Unfortunately, after three years of development, we’re still far from fully achieving those goals. That’s why I spent a year convincing both the design and frontend teams to rethink our approach and accept the idea of moving past the sunk-cost fallacy. In this post, I’ll share some lessons I learned along the way—things I wish we’d known earlier—and explain why we ultimately chose to adopt open-source solutions like Material UI
and Google’s Material Design
.
Design Tokens Are More Than Just Pre-defined Constants
Design tokens aren't just shared constants—they can also define how components are used, almost like an interface or a spec. For example, instead of directly applying tokens.color.primary
to your button's label, you could give it a more specific name like button.label.color
to make it clear that it's a configurable property, much like a function's parameter.
Clarifying Token Roles Across Components
This approach is more than just renaming things. Imagine you’re using tokens.color.primary
in multiple places, like this:
While it’s clear that the button depends on tokens.color.primary
, you can’t easily tell where this token is being used without diving into each instance. (Sure, you could follow a strict naming convention, but that can still get tricky 🫡.)
Now, let’s assign each usage a unique name:
Now, it’s much easier to see where each part of the button uses tokens.color.primary
, making it more maintainable and flexible.
A Different Perspective on Flexibility
I like to think about one thing in many ways. For example, imagine you're writing a function to fetch a book from either the US or Japan, and you want to buy from whichever service responds first.
While this works, it’s not easy to test. So, we usually refactor it like this:
With this version, testing becomes much simpler:
Oh, yes. Just dependency injection.
Sometimes Composition Is A Good Choice
Walking the line between flexibility and consistency is tricky. Configuration offers many benefits, and the most obvious—and important—one is consistency. This approach works particularly well for smaller components, like atoms and molecules. Take a button, for example—its implementation often looks something like this:
The Pitfalls of Over-Configuration
When it comes to larger components, like organisms, a composition approach starts to shine. Now, imagine you're implementing a card component using a configuration-based approach.
The first version of the card might only accept an image, title, description, and one action button. For people who just needed these elements, it was a joy to use. But, as you might expect, not everyone was satisfied with such a basic card. So, in the next update, you added support for a button group to handle multiple action buttons. Shortly after, you introduced an option for displaying a carousel to allow multiple images.
And that was just the beginning. Soon, you planned to make the card collapsible. Later, you added another option to position images on the right, but only if there was a single image.
From a consistency standpoint, the card seemed like a success. But technically, it became a headache. With each new option, the component grew larger and more complex. Eventually, it became a beast that was too hard to maintain.
Any change—whether it was a simple bug fix or a small spacing adjustment—had the potential to affect the entire card component, increasing the risk of introducing bugs into an already functional UI.
Embracing Composition for Flexibility
To avoid the issues that come with over-configuring larger components, switching to a composition-based approach can strike a balance between flexibility and consistency. Instead of constantly updating the card with new configuration options, you can break it down into smaller, more manageable parts:
Basic usage:
Advanced usage:
This way, developers can mix and match pieces to build exactly the card they need without getting stuck with a huge, complicated component. It makes the codebase cleaner, more modular, and way easier to work with.
Oh, yes. Maybe you want to call it Strategy Pattern.
Styling
A design system provides the base styles, but there are still times when we need to override certain components. Picking the right styling method is essential for making these customizations straightforward and efficient. The two biggest concerns? CSS specificity and runtime performance.
Static CSS
It's fast, but has CSS specificity issues.
Static CSS alone often needs help from more advanced techniques, like CSS-in-JS, to overcome these specificity issues and make it feel more like a programmer’s tool.
BEM (Block Element Modifier)
BEM is a popular method for organizing CSS with clear, predictable naming. Everything gets a single class, and nesting is avoided. It’s a good rule for a design system, but the challenge is ensuring all developers follow it consistently. Even with tools like linters to enforce rules, there’s no guarantee someone from Team A won’t accidentally use the same class names as Team B.
CSS Modules
CSS Modules tackle the naming issue by automatically generating unique class names. All class and animation names are scoped locally by default, solving conflicts between styles. However, CSS Modules don’t fully solve specificity issues, which is why developers often combine them with BEM. While this works in simple cases, complex components can still trip things up.
CSS-in-JS
CSS-in-JS libraries like Emotion
provide dynamic solutions to the specificity problem. These tools allow you to write CSS directly within JavaScript, making it flexible and fast. But why not just use them all the time? There are a couple of downsides:
- They introduce significant runtime work.
- They generate monolithic classes.
CSS-in-JS + CSS Variables
Whenever these values change, Emotion
will need to re-generate the class and re-inject it into the document's styleSheets, which can be a performance liability in certain cases (eg. doing JS animations).
Here's another way to solve the problem, using CSS variables:
This trick also enables color mode without flashing 👉 Enable Color Mode with CSS custom properties
Atomic CSS
Atomic CSS is a highly efficient architecture where small, single-purpose classes are named based on their function. It solves both specificity and performance issues by generating lightweight, composable styles.
Zero-runtime CSS-in-JS (w/ Atomic CSS)
Combine the benefits of static CSS and CSS-in-JS, so it's fast and has no CSS specificity issues. There are many teams working on this like Pigment CSS, Panda, StyleX and Griffel.
- Easy to read and maintain
- CSS extraction
- Merge classes
Why Class Merging Matters
Without class merging, the insert order of atomic CSS will affect the style. And in CSS-in-JS world, the insert order is based on the components' render order. So that could cause random behavior, BIG PROBLEM 🚨.
Why LVFHA Order Is Important
Those classes in CSS have the same specificity:
- Link
- Visited
- Focus within
- Focus
- Focus visible
- Hover
- Active
The solution is to insert these rules into separate style elements (style buckets):
Why Shorthands Cause Issues
Order of styles inside JavaScript objects is same, but the resulting CSS is different due to rules insertion order 💣
Other Considerations
- Documentation
- Figma Kit
- Code Examples
- Types
- Accessibility
- Migration
- Performance
- API Consistency
- Patterns
- Component Hierarchy
- Learn Once, Use Everywhere ✨
Conclusion
If you’re looking to build a design system but have limited resources in terms of designers and developers, my advice is simple: start with an existing open-source solution. This will save you time and effort, allowing you to focus on adapting it to your needs rather than reinventing the wheel.
For teams that have designers and developers but lack deep expertise in design systems or UI libraries, it’s still a good idea to build on open-source foundations. Spend time understanding their design and architecture before deciding whether to branch out and create your own or just make some tailored adjustments based on your business domain.
Finally, if you don’t need a full-fledged system and only need shared UI components, any approach works. You can even generate components using AI, explore headless components with atomic CSS tools, or simply pick from open-source libraries—whichever best fits your needs.