Building Design Systems for Web Applications with Figma, Storybook, and npm

Hero image for Building Design Systems for Web Applications with Figma, Storybook, and npm. Image by Balázs Kétyi.
Hero image for 'Building Design Systems for Web Applications with Figma, Storybook, and npm.' Image by Balázs Kétyi.

Design systems are one of those ideas that sound tidy in a slide deck and messy everywhere else. Everybody likes the promise: consistency, speed, reuse, fewer oneoff UI decisions, fewer arguments about spacing, fewer accessibility mistakes repeated in slightly different ways across five teams. Then the real work starts, and we discover that a design system is not a Figma file, and it is not a Storybook instance either. It is a chain of decisions, tooling, conventions, and release discipline that has to survive contact with actual product teams.

That became especially obvious to me on larger platform work. At the World Economic Forum, part of the challenge was helping unify a suite of older digital properties behind a more coherent Reactbased foundation, whilst standardising shared anatomy like navigation, typography, and common UI elements. Later, at Virgin Atlantic, the need was even more explicit: a new design language, a shared component library in Storybook, and a way for multiple applications and teams to consume the same frontend building blocks without each one quietly becoming its own separate universe again.

If I am building a design system for web applications now, I do not start with "let's make a button library". I start with a delivery pipeline:

  • design tokens in Figma
  • an export step that turns those tokens into codefriendly artefacts
  • a shared component library built on those tokens
  • documentation and examples in Storybook
  • a published internal package with versioning and release notes
  • standards and generators that keep the codebase coherent
  • a safe update model for consuming applications

Miss one of those pieces and the system usually turns back into a pile of screenshots and opinions.


Start with Tokens, Not Components

Teams often try to build a design system from the middle. They begin with Button, Card, Modal, Tabs, and a few form fields. That is understandable, because components are the visible part. They are also where drift starts if the lowerlevel decisions are still fuzzy.

The real foundation is the token layer: colour, spacing, type scale, radius, shadows, borders, breakpoints, motion timings, zindex rules, and sometimes icon sizes or grid values. If those values do not exist in a structured, named form, components end up hardcoding design decisions in ways that are annoying to unwind later.

The naming matters as much as the values. I prefer names that survive rebrands and theme changes:

  • colour.text.default
  • colour.surface.brand
  • space.4
  • radius.md
  • font.size.body
  • shadow.overlay

That is usually better than names tied to a single campaign or raw colour chip. If you call a token virginred, you have already made reuse harder. If you call it colour.brand.primary, you have given yourself some room.

This distinction matters even more on multibrand or multitheme platforms. A useful pattern is to keep two layers:

  • primitive tokens: actual values such as #e10a17, 16px, or 0 8px 32px rgba(...)
  • semantic tokens: productfacing names such as colour.action.primary or space.control.padding

Primitive tokens are closer to the raw design system. Semantic tokens are what components should usually consume. That gives you a layer where a brand refresh, dark theme, or accessibility adjustment can happen without rewriting every component.


Extract Tokens from Figma with Code, Not Copy and Paste

If the design team is already working in Figma, that is the obvious source of truth for the token layer. What I do not want is a manual process where somebody reads values out of Figma and pastes them into a TypeScript file every time design changes. That process is slow, easy to get wrong, and almost impossible to trust at scale.

This is where an export step helps. In a more mature setup, I want a script that can read the token definitions from Figma, normalise them, and generate files the component library can actually use.

Figma now exposes variables through its REST API. A simple export script can fetch a token collection, pick the default mode, and serialise the result into JSON, CSS custom properties, or a TypeScript module.

At the time of writing, the variables endpoint requires the right Figma plan and scopes, so it is worth checking that before you promise full automation to the team.

import { writeFile } from 'node:fs/promises';type FigmaColour = {  r: number;  g: number;  b: number;  a: number;};type FigmaAlias = {  type: 'VARIABLE_ALIAS';  id: string;};type FigmaVariableValue = boolean | number | string | FigmaColour | FigmaAlias;type FigmaVariable = {  id: string;  name: string;  variableCollectionId: string;  resolvedType: 'BOOLEAN' | 'FLOAT' | 'STRING' | 'COLOR';  valuesByMode: Record<string, FigmaVariableValue>;  hiddenFromPublishing: boolean;  remote: boolean;};type FigmaVariableCollection = {  id: string;  name: string;  defaultModeId: string;  modes: Array<{    modeId: string;    name: string;  }>;};type FigmaVariablesResponse = {  meta: {    variables: Record<string, FigmaVariable>;    variableCollections: Record<string, FigmaVariableCollection>;  };};type TokenOutput = Record<string, string | number | boolean>;const FIGMA_FILE_KEY = process.env.FIGMA_FILE_KEY ?? '';const FIGMA_TOKEN = process.env.FIGMA_TOKEN ?? '';const TOKEN_COLLECTION_NAME = 'Design Tokens';const toKebabCase = (value: string): string =>  value    .trim()    .replace(/\s*\/\s*/g, '-')    .replace(/\s+/g, '-')    .replace(/[^a-zA-Z0-9-]/g, '')    .toLowerCase();const toHex = ({ r, g, b, a }: FigmaColour): string => {  const channel = (input: number) =>    Math.round(input * 255)      .toString(16)      .padStart(2, '0');  const alpha = a < 1 ? channel(a) : '';  return `#${channel(r)}${channel(g)}${channel(b)}${alpha}`;};const serialiseValue = (  value: FigmaVariableValue,  resolvedType: FigmaVariable['resolvedType']): string | number | boolean => {  if (    typeof value === 'object' &&    value !== null &&    'type' in value &&    value.type === 'VARIABLE_ALIAS'  ) {    return `{${value.id}}`;  }  if (resolvedType === 'COLOR') {    return toHex(value as FigmaColour);  }  return value as string | number | boolean;};const getTokens = async (): Promise<TokenOutput> => {  const response = await fetch(    `https://api.figma.com/v1/files/${FIGMA_FILE_KEY}/variables/local`,    {      headers: {        'X-Figma-Token': FIGMA_TOKEN,      },    }  );  if (!response.ok) {    throw new Error(`Figma request failed with ${response.status}`);  }  const data = (await response.json()) as FigmaVariablesResponse;  const collections = Object.values(data.meta.variableCollections);  const collection = collections.find(    ({ name }) => name === TOKEN_COLLECTION_NAME  );  if (!collection) {    throw new Error(`Token collection "${TOKEN_COLLECTION_NAME}" not found`);  }  return Object.values(data.meta.variables)    .filter(      (variable) =>        variable.variableCollectionId === collection.id &&        !variable.remote &&        !variable.hiddenFromPublishing    )    .reduce<TokenOutput>((accumulator, variable) => {      const rawValue = variable.valuesByMode[collection.defaultModeId];      if (rawValue === undefined) {        return accumulator;      }      accumulator[toKebabCase(variable.name)] = serialiseValue(        rawValue,        variable.resolvedType      );      return accumulator;    }, {});};const run = async () => {  const tokens = await getTokens();  await writeFile(    'src/tokens/generated/tokens.json',    `${JSON.stringify(tokens, null, 2)}\n`  );};void run();

That script is deliberately simple, but it shows the shape of the approach. In a real system I would usually add a few more things:

  • alias resolution so semantic tokens can point to primitive tokens
  • mode support for themes such as light and dark
  • validation for naming conventions
  • separate handling for spacing, typography, and motion values
  • generation of both JSON and CSS custom properties

The important bit is not the exact script. It is that the token pipeline is repeatable and machinereadable.


Turn Exported Tokens into Artefacts the Front End Can Actually Use

A raw JSON dump is useful, but it is not the end product. Most frontend teams want a few outputs from the same token source:

  • CSS custom properties for runtime theming
  • TypeScript exports for compiletime safety
  • sometimes SCSS maps for older codebases or hybrid stacks

That matters because not every consuming application will be built the same way. One may be React with CSS Modules. Another may still be using Sass. Another may use CSS-in-JS. The token pipeline should not force every application into the same styling technology just to share colours and spacing.

I usually prefer CSS custom properties as the most portable runtime format, then add thin TypeScript helpers where useful. That keeps the tokens easy to inspect in DevTools and easy to override by theme.

At the World Economic Forum, a lot of the value in the shared UI work came from standardising the look and feel across sections that had evolved separately for years. Typography, buttons, navigation, and user controls needed to feel like they belonged to one platform rather than several adjacent ones. A good token layer makes that kind of unification much more achievable because it reduces "looks close enough" drift.


Build the Shared Component Library in Storybook

Once the token layer exists, the component library becomes much more straightforward. Every component should consume system decisions rather than smuggling in its own local alternatives.

Storybook is useful here for two reasons. First, it gives designers, engineers, QA, and stakeholders one place to inspect the system in motion. Second, it turns the component library into an actual product. The stories become examples, regression fixtures, and documentation all at once.

At Virgin Atlantic, this part of the work was especially valuable because the library was not being built for one isolated application. It was there to support a broader replatforming effort across multiple experiences. Once a design system has several consuming applications, Storybook stops being a nice extra and starts being the shared contract.

The component code should stay boring. That is usually a good sign.

import classNames from 'classnames';type ButtonProps = {  children: React.ReactNode;  emphasis?: 'primary' | 'secondary';  onClick?: () => void;};export const Button = ({  children,  emphasis = 'primary',  onClick,}: ButtonProps) => (  <button    className={classNames('ds-button', `ds-button--${emphasis}`)}    onClick={onClick}    type='button'  >    {children}  </button>);

The interesting decisions then live in tokenbacked styling rather than oneoff literals:

:root {  --colour-action-primary: #e10a17;  --colour-action-secondary: #ffffff;  --colour-text-inverse: #ffffff;  --space-3: 12px;  --space-4: 16px;  --radius-md: 8px;}.ds-button {  border: 0;  border-radius: var(--radius-md);  padding: var(--space-3) var(--space-4);  font: inherit;}.ds-button--primary {  background: var(--colour-action-primary);  color: var(--colour-text-inverse);}.ds-button--secondary {  background: var(--colour-action-secondary);  color: var(--colour-action-primary);}

That might not look glamorous, but that is the point. The system is doing the heavy lifting. The component is mostly just assembling semantics and accessibility.

The mistake I try to avoid is stuffing too much product logic into the design system. A shared library should own primitives, patterns, and carefully chosen compositions. It should not become a dumping ground for every pagespecific business rule in the organisation. Once that happens, teams either stop trusting it or start forking it.


Package It Like a Real Internal Product

If the library is going to be reused across applications, it needs to be distributed like any other dependency. That usually means publishing it as an internal npm package, ideally with a scope such as @company/designsystem.

That package should expose more than just React components. In most mature setups, I want the package to publish some combination of:

  • components
  • CSS or theme files
  • icon assets
  • token exports
  • TypeScript definitions

The package metadata matters because it shapes how easy the library is to consume.

{  "name": "@company/design-system",  "version": "1.12.0",  "private": false,  "main": "./dist/index.js",  "types": "./dist/index.d.ts",  "exports": {    ".": {      "import": "./dist/index.js",      "types": "./dist/index.d.ts"    },    "./styles.css": "./dist/styles.css",    "./tokens": "./dist/tokens.js"  },  "files": [    "dist"  ],  "publishConfig": {    "registry": "https://npm.pkg.github.com"  }}

Whether this lives in its own repository or in a monorepo depends on the organisation. If all the applications move together, a monorepo with workspaces can be a very pleasant setup. If applications have very different release cadences or sit in different teams, a separately versioned package is often cleaner.

What matters is that the design system has a release model. Someone needs to know what changed, whether it is breaking, and which applications need to care.


Conventions and Generators Do More Work than People Think

The part teams often skip is the operational discipline around the library. Everybody agrees naming conventions are sensible, right up until the fifth engineer creates button.tsx, the sixth creates ButtonComponent.tsx, and the seventh decides stories belong in another folder entirely.

That drift is not morally shocking, but it is cumulative. Over time it makes the library harder to navigate, harder to review, and harder to scale. If you want the system to feel professional after a year, you need some guardrails:

  • clear token naming rules
  • fixed component folder structure
  • consistent prop naming
  • accessibility defaults
  • story naming conventions
  • test conventions
  • linting and formatting

This is exactly the sort of repetitive scaffolding Plop is good at. If every new component starts from the same skeleton, the team spends less time arguing about ceremony and more time building the component itself.

import type { NodePlopAPI } from 'plop';export default (plop: NodePlopAPI) => {  plop.setGenerator('component', {    description: 'Create a design-system component',    prompts: [      {        type: 'input',        name: 'name',        message: 'Component name',      },    ],    actions: [      {        type: 'add',        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.tsx',        templateFile: 'plop-templates/component.tsx.hbs',      },      {        type: 'add',        path: 'src/components/{{pascalCase name}}/{{kebabCase name}}.module.scss',        templateFile: 'plop-templates/component.module.scss.hbs',      },      {        type: 'add',        path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.stories.tsx',        templateFile: 'plop-templates/component.stories.tsx.hbs',      },      {        type: 'add',        path: 'src/components/{{pascalCase name}}/index.ts',        template: "export { {{pascalCase name}} } from './{{pascalCase name}}';\n",      },    ],  });};

This is not about worshipping boilerplate. It is about removing avoidable inconsistency. The more applications and engineers a design system supports, the more valuable that becomes.


Automatic Updates are Never Quite Automatic

One awkward truth about shared packages is that publishing a new version does not magically upgrade every consuming application. The system can generate tokens automatically, build automatically, publish automatically, and still leave all consumers on yesterday's version until they update their dependency and refresh their lockfile.

That surprises people the first time they build one of these platforms.

If an application depends on this:

{  "dependencies": {    "@company/design-system": "^1.12.0"  }}

then a later 1.13.0 or 1.12.3 may be installable, but the application still needs an install or dependency update process before it actually starts using it. Existing lockfiles preserve the old resolved version.

There are a few ways teams try to reduce that friction:

  • use a looser range such as *
  • depend on the latest tag
  • use workspace:* in a monorepo
  • automate update PRs with Renovate or Dependabot

Some of these are useful. Some are dangerous.

workspace:* is great inside a monorepo because local packages stay linked during development. That is often the nicest developer experience if the applications and library genuinely belong in one release train.

Using latest or * for published crossapplication dependencies is a different story. It sounds convenient, but it makes reproducibility weaker and surprises more likely. One team deploys, a new designsystem version lands underneath them, and suddenly they are debugging a regression they did not knowingly opt into. That is rarely worth it on serious platforms.

In practice, I usually prefer controlled automation:

  • semantic versioning on the design system
  • automated release notes
  • Renovate or Dependabot PRs into consuming apps
  • visual regression checks where possible
  • an explicit review step for breaking changes

That gives you most of the convenience without pretending that shared UI changes are riskfree.


Protect the Package Supply Chain Properly

The security side deserves more attention than it usually gets. If you are publishing internal packages and installing them on developer machines or in CI, you need to think about dependency confusion and lookalike package attacks.

The ugly version of the problem is simple: if your organisation uses a weakly named internal package, or relies on a default registry configuration that is too loose, an attacker can publish a similarly named package publicly and hope somebody's machine pulls that instead.

This is not a theoretical concern. The easiest time to fix it is before the system becomes widely used.

The basic protections are straightforward:

  • use scoped package names such as @company/designsystem
  • configure that scope to resolve only from the private registry
  • require authentication in CI and local development
  • keep lockfiles committed
  • avoid unscoped internal package names
  • be careful with copied install commands and ad hoc registry overrides

An .npmrc like this is the sort of thing I want to see:

@company:registry=https://npm.pkg.github.comalways-auth=true//npm.pkg.github.com/:_authToken=${NPM_TOKEN}

I also want the package itself published with an explicit scoped name and registry configuration. Do not rely on tribal knowledge for this. Make the safe path the default path.

If the organisation is large enough, I would go further:

  • use a private registry proxy
  • restrict which external registries CI can reach
  • require code review on release workflow changes
  • audit package provenance and access regularly

The design system will often become one of the most widely installed internal dependencies in the estate. That alone makes it worth treating like supplychain infrastructure rather than frontend decoration.


What the Real Project Work Taught Me

The World Economic Forum

The World Economic Forum work reinforced something I had already suspected: shared UI problems are usually platform problems before they are component problems. The platform had multiple older properties, different levels of polish, and different implementations of common anatomy. Standardising the shared header, footer, user menu, navigation, typography, and common controls was not merely aesthetic. It was a way of making the wider rebuild feel coherent and easier to extend.

It also underlined how often outofthebox component tooling needs refinement. Chakra UI helped the team move quickly, but getting to the finish the designers wanted still required serious attention to theming, token discipline, and standardisation. That is common. A library can accelerate delivery, but it does not remove the need for designsystem judgement.

Virgin Atlantic

Virgin Atlantic made the packaging and governance side impossible to ignore. This was not a single React app with a convenient Storybook beside it. It was a broader transformation effort, bringing together multiple digital properties under a new headless architecture with a shared component library and new design language.

That changes the standards entirely. Once several applications depend on the same library, every designsystem change has consumers, compatibility concerns, release implications, and adoption cost. The Storybook work matters more. The npm package matters more. The token pipeline matters more. The conventions matter more.

It is also where the separation between system and application becomes healthier. The system should make shared patterns cheap and safe. The applications should still own their page logic, data concerns, and productspecific behaviour. That balance is where a design system becomes useful rather than overbearing.


Useful References


Wrapping up

The best design systems are not really about having a pretty component catalogue. They are about creating a reliable path from design decisions to production code, then making that path safe for more than one team and more than one application.

That means treating the work as infrastructure. Figma tokens should flow into code through an export step. Components in Storybook should consume those tokens rather than inventing new values. The library should be versioned and published like a product. Standards should be enforced by tooling rather than memory. Consumer applications should update in a controlled way. And the private package setup should be secure enough that you are not accidentally introducing a supplychain weakness whilst trying to improve consistency.

Key Takeaways

  • A design system is a pipeline and operating model, not just a set of components.
  • Figma token extraction is most useful when it generates repeatable code artefacts rather than manual copy changes.
  • Storybook, internal packaging, conventions, and release discipline are what make a shared library survivable at scale.

Get those pieces right and the design system starts doing what people usually promise on day one: speeding teams up, reducing drift, and making large web applications feel like they belong to the same product family.


Categories:

  1. Architecture
  2. Development
  3. Front‑End Development
  4. Guides