DEV Community

joonquixote
joonquixote

Posted on Originally published at blog.joonquixote.com

A Design System Is Not a Collection of Components

Originally published at https://blog.joonquixote.com/en/posts/a-design-system-is-not-a-collection-of-components/.

A shared component folder and a design system can look much the same from the outside.
Both contain buttons and inputs, and both let you browse components in Storybook.
Can moving a few buttons and inputs into a shared folder and adding Storybook really make it a design system?\
The difference appears once other teams start using those components.

In my previous post, I wrote about a design system I spent two and a half years building, only to see it end when the organization was dissolved. The first thing I identified as missing was adoption.

This post looks at a design system not as a collection of components, but as a structure that captures recurring decisions.
From that perspective, I will examine what must be preserved—and what costs must be paid—across tokens, component APIs, versioning, adoption, and operations.
I will also explain why, in some organizations, a well-organized component folder may be a better choice than a design system.

Turning Recurring Decisions into a System

Describing a design system as a collection of reusable UI components for building consistent interfaces tells only half the story.
Components are the output, and producing that output requires many decisions.
What color should the primary button be? How much padding should a card have? In what order should the buttons appear in a confirmation dialog?
In an organization with only a shared component folder, someone makes these decisions again each time they build a screen.

In a design system, decisions about color, spacing, and typography are encapsulated in tokens, while decisions about recurring UI patterns are encapsulated in components.
Teams using the design system can apply those decisions without making them again.
From this perspective, a component library is only one of the artifacts that make up a design system.
A design system needs not just tokens, components, and documentation, but also a versioning policy and a process for handling change requests.

That makes a design system less like a one-off project and more like a product with consumers.
Like any product, it needs a roadmap for deciding what to build, a channel for user questions, and metrics for determining how much it is actually being used.

On my previous project, I designed and implemented components such as tabs, pagination, and tree from the ground up.
Along the way, I learned that implementing components alone does not sustain a design system.
We also had to establish the development environment and operating practices that would ensure new components were built to the same standards and released through the same process.

Why Components Should Not Know Their Color Values

Tokens are usually divided into two layers.
Primitive tokens define values such as blue-500, while semantic tokens define how those values are intended to be used, with names such as color-primary and color-danger.
Components reference only semantic tokens, never the underlying color values.

:root {
  /* Primitive tokens define values */
  --blue-300: #93c5fd;
  --blue-500: #3b82f6;
  --gray-50: #f9fafb;
  --gray-900: #111827;

  /* Semantic tokens define intent */
  --color-primary: var(--blue-500);
  --color-text: var(--gray-900);
}

[data-theme='dark'] {
  --color-primary: var(--blue-300);
  --color-text: var(--gray-50);
}

/* Components reference only semantic tokens */
.button[data-variant='primary'] {
  background: var(--color-primary);
}
Enter fullscreen mode Exit fullscreen mode

In dark mode, only the values referenced by the semantic tokens change.
The button styles remain untouched.

This structure gives us two things.
First, because the UI refers to intent, switching to dark mode or changing the brand theme is just a matter of swapping values.
Second, designers and developers communicate using the same names.
When the color name in the design matches the variable name in the code, there is less need to compare color values one by one.

Tokens can also be declared in one place and built into platform-specific artifacts.
With a tool such as Style Dictionary, a single JSON file can produce CSS variables for the web alongside artifacts for iOS and Android, preserving the same names across platforms.

Props Should Express Intent, Not Screens

The same principle applies to component APIs.
When deciding which props a button should expose, the button's role—not a particular screen—should be the guide.

Adding isConfirmButton because a button appears on an order confirmation screen is convenient at first.
But each similar request from a payment screen or account deletion screen adds another prop.
If those buttons all represent a primary action, they can be expressed with a single variant="primary".

// Props tied to screen-specific cases grow with every new request
<Button isConfirmButton isPaymentPage hasWarningIcon>
  Pay now
</Button>

// Props based on the button's role
<Button variant="primary">Pay now</Button>

// Composable elements such as icons belong in children
<Button variant="danger">
  <Icon name="warning" />
  Delete account
</Button>
Enter fullscreen mode Exit fullscreen mode

A component that gains props for every screen-specific case does not capture decisions; it becomes a place where screen code accumulates.

There is no need to anticipate every screen's requirements from the start. Composable elements such as icons or supporting text can be handled through children or slots instead of new props. That lets consumers assemble the UI they need without changing the component itself. In a web component, <slot> serves the same purpose.

Even a 4px Change in Button Height Can Be Breaking

A design system can use Semantic Versioning for each release.
The difficult part is deciding what counts as a breaking change.

For a conventional library, compatibility is judged by its public API and behavior. A change to an existing function's name or arguments that breaks compatibility is major; a new feature that preserves existing behavior is minor; and a compatible bug fix is patch. A design system must also account for the visual result.

Changing a button's height from 36px to 40px is a one-line CSS change.
But on a screen where that button sits inside a fixed-height toolbar, it may overflow the toolbar or push adjacent elements out of place.
Major, minor, and patch releases in a design system should be determined by the effect on existing consumers, not by the size of the code change.

Type checking will not catch this kind of change.
The prop types remain the same, and the build still passes.
That is why a design system needs visual regression tests.
This is the biggest difference from versioning a conventional library.

In my previous post, I wrote that rules should not live only in documentation; they should have automated checks that detect violations.
The same applies to visual changes.
Writing down a rule is not enough to keep an unintended style change out of a patch release.
Continuous Integration (CI) needs to detect screenshot differences so that the team can confirm whether a change was intentional before releasing it.

Promote Only Recurring Requirements into the System

Once a design system has been released, consuming teams will ask for changes because a component does not fit their screen.
The first step is to classify the request.
Is it a pattern that will recur across several teams, or an exception needed only on that screen?

If every incoming request is added to the component, its prop list keeps growing until no one can explain its full behavior.
Only patterns proven to recur should become part of the system; the rest should use composition points or remain implementations owned by the requesting team.
A design system also needs criteria for deciding when the same request, repeated across different teams, belongs in a shared component.
Without those criteria, every request leads back to the same discussion.

Keeping the system small is also a matter of quality.
As components and props multiply, so do the combinations that need documentation and testing. That also increases the number of visual regression cases to review.

Consuming Teams Pay the Cost of Upgrading

As a design system ages, teams end up on different versions.
Some use the latest release, while others remain on a version from a year ago.

The reason is simple.
The consuming team owns the code changes and testing required to adopt a new version.
The benefits of removing old APIs and narrowing the maintenance surface, meanwhile, go to the design system team and the organization as a whole.
From the consuming team's perspective, if the current version works, there is little reason to spend time upgrading to a newer version.

There are two main ways to address this problem.

  • Reduce the upgrade burden on consuming teams. Ship a codemod—a script that automatically updates existing code to the new API—and a migration guide with each release. If the change is large, the design system team can also open PRs directly against consuming teams' repositories.
  • Define the supported version range clearly. Supporting only the most recent few versions makes the policy unambiguous. But if the system has not yet earned trust, dropping old versions too quickly may lead teams to abandon the shared components instead of upgrading.

Before either of those steps, reduce the number of changes that break compatibility with existing code.
Instead of removing an old API immediately, provide a deprecation period so that consuming teams can move to the new API on their own schedules.

import type { ReactNode } from 'react';

type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger';
  /** @deprecated Will be removed in v3. Use variant="primary". */
  isPrimary?: boolean;
  children: ReactNode;
};

export function Button({ variant, isPrimary, children }: ButtonProps) {
  if (isPrimary !== undefined && process.env.NODE_ENV !== 'production') {
    console.warn('Button: `isPrimary` is deprecated and will be removed in v3. Use variant="primary".');
  }

  const resolved = variant ?? (isPrimary ? 'primary' : 'secondary');

  return <button data-variant={resolved}>{children}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Keeping the old prop wired to the new one, rather than removing it immediately, lets both APIs remain supported until the next major version.

Figma and Code: Two Sources of Truth

The Figma library maintained by designers and the code package maintained by developers can change independently.
In effect, the same button has two sources of truth.
If the button changes in Figma while the code stays the same, subsequent designs will assume a component that does not exist in code.
The code generator discussed in my previous post was also an attempt to avoid manually reconciling interfaces designed in Figma with their code.

This resembles the dual-write problem in distributed systems.
A dual write records the same data separately in two different stores.
If a change is delayed or never reaches one side, the stores diverge.
When Figma and code change separately, teams likewise end up having to determine which one is current.

There are two alternatives.

  • Maintain one source of truth and propagate it wherever it is needed. Declare tokens in one place, then build a pipeline that updates both Figma variables and code artifacts. This works well at the token level, but it is difficult to keep component structures in sync the same way.
  • Treat Figma changes and code releases as one unit of work. Whenever a component changes, review and release the Figma library and code package changes together. This does not require a separate, complex tool, but it does require everyone involved to follow the same process.

The two approaches can be combined.
Use a pipeline to give tokens a single source of truth, and coordinate component-structure changes that the pipeline cannot handle through the release process.

What You Learn from Operating a Design System

Intentional Change or Regression

Without visual regression tests, it can take considerable time to determine whether a subtle post-release UI difference was intentional or a regression.

The first response is to add the visual regression tests described above to the release pipeline.
The second is to apply the new version to a subset of teams first.
Much like a canary release, validating with one or two teams before rolling a change out to every consumer limits the blast radius when something goes wrong.

Measure Adoption

Adoption determines the success or failure of a design system more than code quality does.
Measuring it requires numbers: usage by component, the share of the overall interface made up of design system components, and the distribution of versions across teams.
Usage can be derived by statically analyzing imports or JSX.
Tools such as react-scanner can report which components are used and which props they receive.

Without these metrics, teams decide what to build first without evidence, and unused components accumulate.
With usage data, they can instead ask whether a component that is almost never used is worth continuing to maintain.

Defects Spread at System Scale

In a design system, a defect in one component can become a defect in every product that uses it.
For example:

  • Change the value of one semantic token, and the colors change in every product that references it.
  • Let a style change slip into a patch release, and a team using a dependency range such as ^1.2.0 will pick it up the next time it updates its lockfile.
  • A system-level accessibility defect, such as a button with no focus indicator, spreads to every screen that uses that button.
  • Turn on dark mode, and all the hard-coded colors that bypass the token system stand out at once.

The last case can also be used as a test. Apply dark mode or a temporary theme, and colors that do not use tokens stand out.

Static analysis is also useful if you do not want to perform these checks manually every time. For example, Stylelint's color-no-hex rule can flag direct uses of HEX color values. Restricting color formats beyond HEX requires additional rules, but even this check can catch colors that bypass the token system during development.

The More You Lock Down, the More Workarounds Appear

To keep component behavior consistent, it is better not to let consumers override styles or behavior arbitrarily. But a design system matters only if multiple teams actually use it, and those teams can always choose to build their own components instead of using the shared ones.

When extension is restricted too heavily in the name of consistency, teams whose requirements are not met work around the system by copying components or overriding their styles. As this code accumulates, unmanaged components pile up outside the system, and consistency eventually breaks down anyway.

At the other extreme, accepting every consuming team's request bloats the components inside the system, as described earlier.

The answer is to separate the standards that must be protected from the choices that consuming teams can make for themselves. The system should own token semantics, accessibility, and the default behavior of shared components. For other requirements, it is better to provide composition points or allow team-specific implementations.

Even when team-specific implementations are allowed, the system team should be able to see where exceptions exist and what they are. For example, apply a naming convention to custom components and include them in the usage analysis described above. That makes it possible to see which teams are implementing what for themselves.

The exceptions will not disappear entirely, but the system team will know about them. If several teams are using the same workaround, the system team can reconsider whether the requirement belongs in a shared component.

When a Design System Fits the Organization

A design system can do its job when multiple products and teams are making the same decisions repeatedly.
My previous project began with teams using the design system in React, Vue, and Angular, each implementing components with the same design on their own.

If one team is building one product, by contrast, operating a token pipeline, versioning policy, and adoption metrics may cost more than it returns.
In that situation, a well-organized component folder is enough.
Within one team, making a decision again is not especially expensive, and coordinating versions happens inside a single repository.

If you are starting a design system or already operating one, four questions can help you determine where you stand:

  • Are two or more teams repeatedly making the same decisions?
  • Do you have a mechanism that detects visual changes automatically?
  • Who pays the cost of adopting new versions and updating existing code?
  • Can you quantify which components are used by how many teams, and on which versions?

In my previous post, I wrote that although I had explained what I built, I had failed to carry it through to actual adoption.
Building components ends with the first release, but getting people to use them starts over with every release.
That is why I now think a statement about how many teams use which versions describes a design system more accurately than saying that the system has been built.

Top comments (0)