Creating and maintaining UI library or an entire design system is complex. Building one from scratch can be a challenge, so let’s prepare the project to make it easier.

Hi, my name is Tomasz and I am a former manager and tech lead. Today I want to show you how to prepare proper scaffolding for a design system development environment.

Before we start, let’s lay some ground rules:

  • This is part zero of a larger series, in which we will explore the whole topic of developing a design system, from development environment (we are here), through tokens and grouping, to even testing the components.
  • I picked React, but this architecture is viable for every other library. I am not using any particular features that only React has.
  • I am using Tailwind due to its great customization options paired with tons of ready-made code, but this is entirely optional. Vanilla CSS or any other framework works perfectly fine.
  • In the upcoming parts, I will be using Atomic Design methodology by Brad Frost. I find it the most scalable solution as of yet for mid to large systems.
  • This is not a design tutorial. I won’t go into details on how things should look with each other, but I will dive into how they should work with each other.

All right, let’s start writing some code!

Bootstrapping and Storybook

We start by creating a new project using Vite:

~ npm create vite@latest react-design-system -- --template react-ts

This will create a new directory called react-design-system in which we will find all the required things to start. After the process is completed, we need to install the dependencies:

~ cd react-design-system
~ npm i

Just to be sure, let’s run the development command:

~ npm run dev

Works! Great. Now let’s start with the actual environment.

First off, Storybook. It’s the golden standard for developing UI, and supports many libraries out of the box, including React, Vue, Angular and Svelte. So, in our main directory:

~ npx storybook@latest init

Historically, Storybook was bundled by Webpack, and it is still supported. But it also support Vite, both out of the box. As you can see, the installer itself decided what to use, so that’s all we had to do!

One thing is that Storybook collects some telemetry. If you’re fine with this, that’s completely fine, but if you want to opt out, go to ./.storybook/main.ts and add

core: {
  disableTelemetry: true,
},

to the config object.

Installing Storybook today is as seamless as can be. I still remember how problematic it could be back in the late 2010s, when Webpack came with tons of config and you had to adjust every bit of it for your app and Storybook’s just to make it render the same.

Adding Tailwind

The next step is to add Tailwind. Tailwind is pretty seamless to add to the project, so let’s start.

First, we need to install the library and its dependencies:

~ npm i -D tailwindcss postcss autoprefixer

After this ends, we can initialize it:

~ npx tailwindcss init -p

By adding -p flag, the wizard will also create PostCSS config that will then utilize autoprefixer.

Right, so let’s configure Tailwind to see our files. In the config file (tailwind.config.js), set config property to:

content: ["./index.html", "./src/**/*.{ts,tsx}"],

This will make Tailwind scan aforementioned files.

Now create style.css file in ./src and put the basics:

@tailwind base;
@tailwind components;
@tailwind utilities;

And import the file in main.tsx:

// ./src/main.tsx

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./style.css";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

That’s it for the app. We can run the dev mode and see if it works.

But that’s not everything. Storybook doesn’t know we want to use Tailwind and won’t see it. Luckily, this is an easy fix. Go to ./storybook/preview.ts and import the CSS file we’ve made earlier:

import "../src/style.css";

That’s it!

Adding linters

One of the most crucial things in development is code style. I know it sounds funny and you might think I am overreacting, but fighting over spaces, quotes and line lengths can often balloon and become a real problem. A good way to solve the issue early is to throw Prettier and ESLint in. Let’s do it now.

~ npm i prettier -D

This will add the prettier package to our project. We need to configure it, so let’s create the config file:

/**
 * @type {import("prettier").Config}
 */
export default {
  semi: true,
  singleQuote: false,
  tabWidth: 2,
  trailingComma: "all",
};

I like to have this as a standard: always add semicolons, use double quotes, use two spaces and add trailing commas to all elements.

While Prettier can work as a command line utility, I strongly suggest configuring your IDE to reformat the file on save. In IntelliJ this is done via Settings > Languages & Frameworks > JavaScript > Prettier, and in there, pick “Automatic Prettier Configuration” and check “Run on Save”.

To start fresh and have all the files formatted correctly, go into terminal and run

~ prettier -w ./*

This will reformat and write (hence the -w flag) all files in the project.

As for ESLint, Vite adds it automatically, and with decent settings to boot. For the sake of brevity, I will leave it as-is. You can, obviously, modify it to your needs and make it as lax or as strict as you want. One thing to remember here is that, by default, ESLint uses its new “flat” config, and some plugins aren’t compatible. There’s FlatCompat class exposed by @eslint/eslintrc, but I had mixed results with it.

Defining our first component

If all is configured, let’s get to work. There’s stories directory created by Storybook which serves as an example repository. You can keep it, be we won’t be using it. We’ll create another “space” for the UI, named, well, ui. From there, we will export all the public components to use across the app.

I like to have a global export file in every space, and with that, a shortcut defined in tsconfig.json. Something like

@path/* -> ./src/path/*
@path -> ./src/path/index.ts

I won’t dive into Atomic Design today to save time, so let’s just create a basic component in the UI space to get it going!

// ./ui/Info/Info.tsx

import type { ReactNode, HTMLAttributes } from "react";

interface Props extends HTMLAttributes<HTMLDivElement> {
  variant?: "info" | "warning" | "error";
}

export default function Info({ className, children, variant = "info" }: Props) {
  const cls: string[] = [className || ""];

  switch (variant) {
    case "warning": {
      cls.push("bg-yellow-100 text-yellow-800 border-yellow-500");
      break;
    }

    case "error": {
      cls.push("bg-red-100 text-red-800 border-red-500");
      break;
    }

    default:
    case "info": {
      cls.push("bg-blue-100 text-blue-800 border-blue-500");
      break;
    }
  }

  return (
    <div
      role="alert"
      className={"p-4 mb-4 text-sm text-blue-800 rounded-lg" + cls.join(" ")}
    >
      {children}
    </div>
  );
}

We could (and will, soon) add more to the directory, like styles and tests. But for now, just this file. And let’s export it.

// ./ui/index.ts
export { default as Info } from "./Info/Info";

And let’s add the TypeScript paths.

"compilerOptions": {
  "paths": {
    "@ui": ["./src/ui/index.ts"],
  }
}

But, this won’t work just yet! We need to install vite-tsconfig-paths to allow Vite to understand it.

~ npm i -D vite-tsconfig-paths

And add it in the config. I suggest putting it first just to be safe.

// ./vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths(), react()],
});

Great, so we have our component, but how to see how it looks? Well, you might throw it in the main file, but there’s a better way.

Writing stories

To create a story, first create its file: Info.stories.tsx. The process is rather straightforward, and mostly consists of configuration.

import type { Meta, StoryObj } from "@storybook/react";
import Info from "./Info";

const meta: Meta<typeof Info> = {
  title: "UI/Info",
  component: Info,
};

export const Primary: StoryObj<typeof Info> = {
  args: {
    children: "Hello from Storybook",
  },
};

export default meta;

Let’s go through the file. First, we import types and the component in question. Then we define the meta where all the info that Storybook digest resides. Note the slash in the title – adding each creates a new “directory”, so right now we have UI → Info, but soon we’ll expand this.

Last, but not least, is the definition of the story, named “Primary” as per standard. But the naming is unimportant, it can be whatever, as long as it’s a valid component (so, as long as it starts with a capital letter).

Okay, we got this, so let’s try if it works!

~ npm run storybook

If you see the Storybook page, and in the sidebar there’s an “UI” space, all works great!

Adding Plop to generate scaffolding

This is all fine and well, but creating such components and stories will surely prove exhausting, right?

Yes. You can trust me here, it will get old very, very fast.

That’s why we can use a generator that will create empty components for us. Plop can do this.

~ npm i -D plop

And create the config file in the root, named plopfile.mjs. In there, we can define what we want to generate. Let’s start by defining a UI component:

// ./plopfile.mjs

function plop(/** @type {import('plop').NodePlopAPI} */ plop) {
  plop.setGenerator("ui", {
    description: "Create a new UI component",
    prompts: [
      {
        type: "input",
        name: "name",
        message: "Component name",
      },
    ],
  });
}

export default plop;

Right now, it does nothing. Simply defines that such generator exists and asks for the name, but to actually write files, it needs an “action”.

// ./plopfile.mjs

actions: [
  {
    type: "add",
    path: "./src/ui/{{pascalCase name}}/{{pascalCase name}}.tsx",
    templateFile: "./plop-templates/Component.tsx.hbs",
  },
  {
    type: "add",
    path: "./src/ui/{{pascalCase name}}/{{pascalCase name}}.stories.tsx",
    templateFile: "./plop-templates/Story.tsx.hbs",
  },
  {
    type: "append",
    path: "./src/ui/index.ts",
    template: 'export { default as {{pascalCase name}} } from "./{{pascalCase name}}/{{pascalCase name}}";',
  },
],

Let’s go from the top. First, we add a new file, with path being src/ui/Name/Name name.tsx. If you look into the “prompts”, you’ll see that there’s name property that is reflected in here. It also uses a template that we are yet to write.

Second action is the same, it just has the story. And the third one is appending the export to the main index.ts file.

Right, so let’s create the templates! It’s written in Handlebars and, frankly, it’s quite straightforward.

// ./plop-templates/Component.tsx.hbs

import type { ReactNode, HTMLAttributes } from "react";

interface Props extends HTMLAttributes<HTMLDivElement> {
  children: ReactNode;
}

export default function {{pascalCase name}}(props: Props) {
  return (
    <div {...props} />
  );
}

As you see, it’s very barebones. That’s by design, because eventually we will expand component types and have different templates. For now, this will suffice. Same with the story:

// ./plop-templates/Story.tsx.hbs

import type { Meta, StoryObj } from "@storybook/react";
import {{pascalCase name}} from "./{{pascalCase name}}";

const meta: Meta<typeof {{pascalCase name}}> = {
  title: "UI/{{pascalCase name}}",
  component: {{pascalCase name}},
};

export const Primary: StoryObj<typeof {{pascalCase name}}> = {
  args: {
    children: "Hello from Storybook",
  },
};

export default meta;

Lastly, let’s add the shortcut to npm scripts:

"scripts": {
  ...

  "plop": "plop"
},

There’s one thing I like to do, and it’s to have Prettier run through the files after we are done. It’s achievable with post-scripts:

"scripts": {
  ...

  "plop": "plop",
  "postplop": "prettier --write 'src/**/*.{ts,tsx}'"
},

Right, so let’s test!

~ npm run plop

If everything worked, we should see a success report. All that’s left is to fire up Storybook and see if all is fine!

UI libraries are complex, and the devil is always in the details. Today we’ve managed to create a solid ground for development. Join me in the next videos from the series, where we will dive into tests, visual regressions and atomic design.