Jaye Hackett

One of the most exciting developments in design for me in the past few years was the launch of the GOV.UK design system. Of course, the real power of a design system comes from the community of designers who maintain it, but GOV.UK does so much right that it made me want to raise the bar on my own projects.

I wanted to make something that was:

  1. easily reused in any website or app by installing it as an npm package
  2. gives you a documented, public demo site on the web, friendly to non-developers
  3. lets you conveniently test the components

Here’s how I made it.

It’s theoretically quite easy to publish something to npm. You create an account on, run npm login and then run npm publish. Your current project directory will get packaged up and put on the web.

But there’s a few deceptive problems that crop up:

Modules containing JSX need to be transpiled first

I’d foolishly assumed that I could simply ship my untranspiled JSX code in my npm package, and leave it up to the app that consumed the package to do the transpilation and bundling.

This doesn’t work — a few syntax errors later, I’d integrated the rollup bundler. My config is as simple as I could make it:

// rollup.config.js 

export default {

input: "src/index.js",

output: {

file: "dist/bundle.js",

format: "cjs",

globals: { "styled-components": "styled" }


plugins: [




extensions: [







exclude: "node_modules/**"




The config is:

  • transpiling JSX into ordinary JavaScript with Babel
  • resolving image files (like .svgs) into base64 strings which can be embedded in the output
  • making sure that dependencies that the module needs, especially react, react-dom and styled-components don’t end up in the bundled code, to avoid bloat.

I experimented with using parcel (because I like zero-config solutions) and webpack first, but I found both frustrating. Rollup is designed with bundling libraries and packages in mind.

You can use an .npmignore file to keep the unneeded source files out of the published module.

Crucially, you also need to remember to change the "main" value in package.json to wherever the output bundle ends up. For instance:

"main": "dist/bundle.js"

When you import { Whatever } from "my-module" , this is the file you’re importing from, so it must match.

I don’t want to manually push to npm as well as to Github

Ideally, I would use CircleCI or some other continuous deployment server to run tests, build a bundle and push it to npm.

I experimented with using the semantic-release package to handle this for me, which looks for keywords in your commit messages to decide when and how to increment your version number and publish.

I eventually decided this was more trouble than it was worth and wrote a simple npm script to make sure that my tests and build run before publishing whenever I hit npm publish:

// package.json"scripts": {    


"prepare": "jest && rollup -c",



Publishing lots of components in one package

There are multiple patterns for exporting a large number of components. Scoped packages are one way. The pattern I decided on was destructured imports:

import { Button } from "my-design-system"

To make this work, I structured my files this way:


|-- Button/

|-- Headline/

|-- Card/

|-- index.js

Every component has at least one named export (no defaults), and index.js looks like this:

// src/index.jsexport * from "./components/Button"

export * from "./components/Headline"

export * from "./components/Card"


There are a few approaches for doing this, but the most popular at the moment seems to be Storybook.

I found Storybook pretty easy to install for React, and it’s big range of add-ons are promising:

Some of the Storybook add-ons

In my npm scripts, I wrote an npm run dev that starts storybook and starts rollup watching for changes, so that the package is in an importable state if I decide to develop with it locally in a different app using npm link.

// package.json"scripts": {                           

"build": "rollup -c",

"dev": "rollup -cw & start-storybook",

"storybook": "start-storybook",

"build-storybook": "build-storybook",


I co-located my stories like this:




The nature of a design system means that most components can get away with being stateless: they have predictable outputs based wholly on their props.

Any stateful logic will propably be in the apps that consume the design system.

Luckily, this means we can use Jest’s snapshot testing for the bulk of our testing needs.

Storybook has a wonderfully useful add-on called Storyshots which takes the already-written stories and uses them as the test cases for snapshots. No need to duplicate!

The config is very simple:

// .storybook/storyshots.test.jsimport initStoryshots from "@storybook/addon-storyshots"  


With that set up, I can run jest based on my Storybook stories.

eslint, and especially jsx-a11y are also useful to catch possible accessibility bugs. npm test is set up to do all this:

// package.json"scripts": {


"pretest": "eslint src/ --ext .jsx",

"test": "jest"



All of this was relatively painless compared to the challenge of developing the design system locally, using npm link.

npm link lets you hook up a local project on your computer as if it were a “real” package from the web.

Whenever I tried to consume my local package in an example React app, I got horrendous errors warning me of invalid hook calls, multiple versions of React and eventually multiple instances of styled-components running on the page.

This was all despite me excluding these libraries from my bundled code using Rollup’s externals feature.

It turned out that a bug in a recent version (5.0.0) of styled-components was to blame. Temporarily downgrading or migrating to another CSS-in-JS package entirely fixed it.

This isn’t an ideal fix, but it underscores the occasional difficulty of getting a good local development workflow, even when the end user experience is fine.

Once this styled-components issue is solved, I’ll make a blank, reusable boilerplate kit available here.

Leave a Reply

Your email address will not be published. Required fields are marked *