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

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 one‑off 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 React‑based 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 front‑end 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 code‑friendly 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 lower‑level decisions are still fuzzy.
The real foundation is the token layer: colour, spacing, type scale, radius, shadows, borders, breakpoints, motion timings, z‑index rules, and sometimes icon sizes or grid values. If those values do not exist in a structured, named form, components end up hard‑coding 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.defaultcolour.surface.brandspace.4radius.mdfont.size.bodyshadow.overlay
That is usually better than names tied to a single campaign or raw colour chip. If you call a token virgin‑red, 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 multi‑brand or multi‑theme platforms. A useful pattern is to keep two layers:
- primitive tokens: actual values such as
#e10a17,16px, or0 8px 32px rgba(...) - semantic tokens: product‑facing names such as
colour.action.primaryorspace.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 machine‑readable.
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 front‑end teams want a few outputs from the same token source:
- CSS custom properties for runtime theming
- TypeScript exports for compile‑time 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 token‑backed styling rather than one‑off 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 page‑specific 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/design‑system.
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
latesttag - 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 cross‑application dependencies is a different story. It sounds convenient, but it makes reproducibility weaker and surprises more likely. One team deploys, a new design‑system 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 risk‑free.
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/design‑system - 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 supply‑chain infrastructure rather than front‑end 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 out‑of‑the‑box 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 design‑system 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 design‑system 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 product‑specific behaviour. That balance is where a design system becomes useful rather than overbearing.
Useful References
- Figma Variables REST API, https://developers.figma.com/docs/rest‑api/variables/
- Storybook documentation, https://storybook.js.org/docs
- npm scopes documentation, https://docs.npmjs.com/about‑scopes
- Plop, https://plopjs.com/
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 supply‑chain 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.
Related Articles

Caching Strategies for Data Fetching in Next.js. 
Staying Current: Automating Copyright Year Updates. Staying Current: Automating Copyright Year Updates

Object Property Shorthand and Computed Property Names in JavaScript. Object Property Shorthand and Computed Property Names in JavaScript
Where to Find Jobs in Web Development. Where to Find Jobs in Web Development

Positioning in CSS. Positioning in CSS

Using Viewport Units in CSS: vw and vh. Using Viewport Units in CSS:
vwandvh
Resolving mini‑css‑extract‑plugin Warnings in Gatsby. Resolving
mini‑css‑extract‑pluginWarnings in Gatsby
Unravelling JavaScript: Commonly Misunderstood Methods and Features. Unravelling JavaScript: Commonly Misunderstood Methods and Features

Using Vue's Suspense for Asynchronous Components. Using Vue's Suspense for Asynchronous Components

Interpolation: Sass Variables Inside calc(). Interpolation: Sass Variables Inside
calc()Handling Click Events in JavaScript. Handling Click Events in JavaScript
Looping in JavaScript ES5 and ES6: forEach and for...of. Looping in JavaScript ES5 and ES6:
forEachandfor...of