Why We Stopped Building Our Own Design System From Scratch

Introduction

cover image

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.

.button__label {
  /* ❌ Don't */
  color: var(--tokens-color-primary);
 
  /* ✅ Do */
  color: var(--button-label-color);
}

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:

.button__start-icon {
  color: var(--tokens-color-primary);
}
 
.button__end-icon {
  color: var(--tokens-color-primary);
}
 
.button__label {
  color: var(--tokens-color-primary);
}

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:

:host {
  --button-start-icon-color: var(--tokens-color-primary);
  --button-end-icon-color: var(--tokens-color-primary);
  --button-label-color: var(--tokens-color-primary);
}
 
.button__start-icon {
  color: var(--button-start-icon-color);
}
 
.button__end-icon {
  color: var(--button-end-icon-color);
}
 
.button__label {
  color: var(--button-label-color);
}

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.

async function buyTheBook(isbn) {
  const book = await Promise.race([
    fetch(`https://www.amazon.com/dp/${isbn}`),
    fetch(`https://www.amazon.co.jp/dp/${isbn}`),
  ]);
  // ... buy the book and return the region
}

While this works, it’s not easy to test. So, we usually refactor it like this:

async function buyTheBook(isbn, fetchUsBook, fetchJpBook) {
  const book = await Promise.race([
    fetchUsBook(isbn), 
    fetchJpBook(isbn)
  ]);
  // ... buy the book and return the region
}

With this version, testing becomes much simpler:

it("should buy from US if its API is faster", async ({ expect }) => {
  // ...
  const buyBookPromise = buyTheBook(
    "1718501854",
    fetchUsBookMock,
    fetchJpBookMock
  );
  fetchUsBookMock.mock.results[0].value.resolve(new Response("🇺🇸"));
  expect(await buyBookPromise).toEqual("🇺🇸");
});
 
it("should buy from JP if its API is faster", async ({ expect }) => {
  // ...
  const buyBookPromise = buyTheBook(
    "1718501854",
    fetchUsBookMock,
    fetchJpBookMock
  );
  fetchJpBookMock.mock.results[0].value.resolve(new Response("🇯🇵"));
  expect(await buyBookPromise).toEqual("🇯🇵");
});

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:

<Button
  variant="outlined"
  color="primary"
  size="large"
  startIcon={<PetsIcon />}
  endIcon={<SpaIcon />}
>
  Join Pets Spa
</Button>

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:

<Card>
  <CardHeader />
  <CardMedia />
  <CardContent />
  <CardActions />
</Card>

Basic usage:

<Card>
  <CardMedia component="img" image="/assets/kirby.webp" />
  <CardContent>
    <Typography variant="h5">Kirby</Typography>
    <Typography variant="body2">Pink. Puffy. Powerful.</Typography>
  </CardContent>
  <CardActions>
    <Button>Kirby amiibo</Button>
  </CardActions>
</Card>

Advanced usage:

<Card>
  <CardMedia component="div">
    <Carousel
      images={[
        "/assets/pink.webp",
        "/assets/puffy.webp",
        "/assets/powerful.webp",
      ]}
    />
  </CardMedia>
  <CardContent>
    <Typography variant="h5">Kirby</Typography>
    <Typography variant="body2">Pink. Puffy. Powerful.</Typography>
  </CardContent>
  <CardActions>
    <IconButton>
      <KirbyPunchIcon />
    </IconButton>
    <IconButton>
      <KirbySwallowIcon />
    </IconButton>
    <ExpandMore expand={expanded} onClick={handleExpandClick}>
      <ExpandMoreIcon />
    </ExpandMore>
  </CardActions>
  <Collapse in={expanded}>
    <CardContent>
      <Typography>
        Don’t let the adorable face fool you—this powerful, 
        pink puff can pack a punch! Since 1992, Kirby has 
        been battling baddies across dozens of games. With 
        his unique abilities, Kirby is always finding new 
        ways to take on troublemakers.
      </Typography>
    </CardContent>
  </Collapse>
</Card>

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.

base-styles.css
.ui.vertical.menu {
  color: #ccc;
  margin: 0;
}
overrides.css
.menu {
  margin: 5px;
} /* ❌ not enough specific */
.ui.menu {
  margin: 5px;
} /* ❌ not enough specific */
 
.ui.vertical.menu {
  margin: 5px;
} /* ✅ that works */
#foo-menu {
  margin: 5px;
} /* ✅ that works, too */

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.

.button {
  color: #ccc;
}
.button.primary {
  color: #fff;
}
 
/* BEM approach */
.button {
  color: #ccc;
}
.button--primary {
  color: #fff;
}

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.

/* "MODULE" represents hash produced by CSS modules */
 
/* Combined selectors */
.MODULE__button.MODULE__primary {
  color: #fff;
}
 
/* ⬇️ To a single selector with BEM */
.MODULE__button--primary {
  color: #fff;
}

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:

  1. They introduce significant runtime work.
  2. They generate monolithic classes.
.base {
  color: red;
  font-size: 32px;
}
.overrides {
  color: blue;
}
 
/* merge to this during runtime */
.base-n-overrides {
  color: red;
  font-size: 32px;
  /* 👇 wins based on order */
  color: blue;
}

CSS-in-JS + CSS Variables

function Backdrop({ opacity, color, children }) {
  return (
    <Wrapper opacity={opacity} color={color}>
      {children}
    </Wrapper>
  );
}
const Wrapper = styled.div`
  opacity: ${(p) => p.opacity};
  background-color: ${(p) => p.color};
`;

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:

function Backdrop({ opacity, color, children }) {
  return (
    <Wrapper
      style={{
        "--color": color,
        "--opacity": opacity,
      }}
    >
      {children}
    </Wrapper>
  );
}
const Wrapper = styled.div`
  opacity: var(--opacity, 0.75);
  background-color: var(--color, var(--color-gray-900));
`;

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
const stylesA = {
  paddingLeft: "pl-10",
  color: "c-ref",
};
const styleB = {
  color: "c-blue",
};
 
/* 1. Merge objects to remove duplicate keys */
const mergedStyles = {
  ...stylesA,
  ...stylesB,
};
 
/* Get CSS classes */
const classes = Object.values(mergedStyles);
// ➡️ ["pl-10", "c-blue"]

Why Class Merging Matters

.blue {
  color: blue;
}
.red {
  color: red;
}
<span class="red blue">
  Hello! What color I have?
  <!-- 🍎 -->
</span>

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
import Frame from "react-frame-component";
 
export default function App() {
  return (
    <div>
      <p>
        Both frame render the same CSS, but order of appearance is different.
      </p>
 
      <Frame>
        <style>{`.button:hover { color: red;  }`}</style>
        <style>{`.button:focus { color: blue; }`}</style>
 
        <button className="button">Hello, focus & hover me</button>
      </Frame>
      <br />
      <Frame>
        <style>{`.button:focus { color: blue; }`}</style>
        <style>{`.button:hover { color: red;  }`}</style>
 
        <button className="button">Hello, focus & hover me</button>
      </Frame>
    </div>
  );
}

The solution is to insert these rules into separate style elements (style buckets):

// Catch all other
<style data-make-styles-bucket="d" />
// :link
<style data-make-styles-bucket="l" />
// :visited
<style data-make-styles-bucket="v" />
// :focus-within
<style data-make-styles-bucket="w" />
// :focus
<style data-make-styles-bucket="f" />
// :focus-visible
<style data-make-styles-bucket="i" />
// :hover
<style data-make-styles-bucket="h" />
// :active
<style data-make-styles-bucket="a" />
// @keyframe
<style data-make-styles-bucket="k" />
// @media, @supports, etc.
<style data-make-styles-bucket="t" />

Why Shorthands Cause Issues

Order of styles inside JavaScript objects is same, but the resulting CSS is different due to rules insertion order 💣

import css from "atomic-css-in-js";
 
const classNameB = css({
  padding: 5,
  paddingTop: 10,
});
 
// 👇 Result
// .padding { padding: 5px; }
// .padding-top { padding-top: 10px; }
import css from "atomic-css-in-js";
 
const classNameA = css({
  paddingTop: 10,
});
const classNameB = css({
  padding: 5,
  paddingTop: 10,
});
 
// 👇 Result, order is changed
// .padding-top { padding-top: 10px; }
// .padding { padding: 5px; }

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.