Web Component Library Part 1: Create a Library With Lit and Storybook

Write once, run anywhere. Please, don’t run away! You are in the right place to read about web components and components library.

The article is the starting point of a series about writing a component library using web components with the Lit library.

[!NOTE] 2026 Disclaimer: this was a tentative for a post series regarding web components but is discontinued. I keep it for reference and archiving purpose only.

About the library

Why building a component library when there are so many existing libraries? Those components translate a significant part of your design system and consequently your branding. A library, independent from any of your applications, guarantees the consistency and prevents developers from re-inventing the wheel whenever a new application is required.

The usual downside is IE11: web components work best with evergreen browsers. Lit elements documentation mentions possible polyfills but make sure it is worth the effort.

Objectives: requirements and solutions

The objective is to lay the groundwork for a library powered by web components. This exercise attempts to mimic some real world situation where an organisation is significantly huge/complex enough to embrace multiple stacks such as front-end frameworks/libraries, like Vue.js, React.js, etc, but also templating engines via frameworks such as Ruby on Rails, Django etc, or even vanilla HTML (who knows). First requirements are:

  • Interoperability is a must to have the library working independently from the application stack. You’ll love this point if when you’ll have to switch stack.
  • Review can be done by both developers and designers. For the latter, a visual component rendering is required to have the components validated from a design perspective.

Some requirements will only be mentioned or partially covered. Dedicated articles are in-progress.

  • Styling has to be exportable, mainly for utilities or basic styling such as buttons.
  • Unit testing must prevent regression from happening
  • Distribution has to be done in both way: as a NPM package and via a CDN.

To address each point:

  • Interoperability is covered by web components custom elements technology.
  • Review by designers is done with Storybook, optionally with Chromatic.
  • Styling is exported with dedicated SCSS stylesheets with great fanfare (understand here, PostCSS).
  • Unit testing is handled by Open WC testing library, not showcased in this article.
  • Library is built with Rollup, partially covered in in this article, and the CDN distribution is powered by Unpkg.

The Open Web components organisation provides an awesome generator: npm init @open-wc (documentation here). While this is a robust option, its Storybook configuration currently lacks of flexibility that I often need in my work.

Web components

This article will not include an introduction to web components. To explore web components, here are some reading suggestions:

In short, Shadow DOM ensures that our components styling will not collide with other components or the styling used by the consuming application. HTML templates, especially with <slot>, provides some flexibility. Lastly, Custom elements offers the interoperability we want as previously mentioned.

Although those technologies will be abstracted by Lit in this article, it is always good to know a bit about what’s happening under the hood: there is no black magic involved so rest assured that no goat will be harmed when following this guide.

Setup the library

Create the project

The first steps are the usual NPM package ceremony:

# Create the library folder
mkdir <folder path> && cd <folder path>

# Initialise the project by answering the questions
npm init

# Initialise the git stuff if necessary
git init
touch .gitignore

Fill the .gitignore, for example with:

# Dependencies
node_modules

# Web component library build outputs
dist
# Storybook build outputs
storybook-static

# Various tools logs
*.log

Let’s pour some TypeScript flavour in our project:

# No specific TypeScript version is required for this project
npm install --save-dev typescript

In a professional environment, I prefer only having exact version defined in the package.json to ensure all dependencies are compatible with each other.

A tsconfig.json can be initialised with npx tsc --init. Otherwise, Lit documentation recommends the following tsconfig.json configuration (documentation source):

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2021",
    "module": "es2015",
    "moduleResolution": "node",
    "lib": ["es2021", "dom"],
    "declaration": true,
    "declarationMap": true,
    "experimentalDecorators": true,
    "useDefineForClassFields": false,
    // This path is specific to this article
    "paths": {
      "@wcl-main/*": ["./src/*"]
    }
  }
}

For reference, tsconfig.json documentation is here.

The @wcl-main path alias will not be that useful in this article and is defined only for the sake of having path aliases properly configured in both Storybook and Rollup.

Our target being evergreen browsers, ES modules syntax is our way to go! No more room for CommonJS…well, except for some configuration files. (Don’t be afraid if you don’t see any Babel stuff in this article!)

I recommended reading the ES modules guide from Modern Web to know more about it.

Add linting and formatting utilities

Optionally, the iconic ESLint & Prettier duo can be invited to the party for some code linting and formatting:

# Install ESLint
npm install --save-dev eslint

# Install Prettier along with plugin and configuration
npm install --save-dev prettier eslint-config-prettier eslint-plugin-prettier

# Install TypeScript parser and plugin
npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser

At the time of writing/testing this article, ESLint plugin requires ESLint v8. Compatibility with the latest ESLint v9 is in-progress:

# Install ESLint v8
npm install --save-dev eslint@^8.0.0

A simple ESLint configuration inspired by configuration from the Lit Element TypeScript starter kit:

// .eslintrc

{
  // If you are part of a monorepo, this can be useful
  "root": true,
  "env": {
    "browser": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module"
  },
  "plugins": ["@typescript-eslint", "prettier"]
}

Prettier configuration is pretty much (no pun intended) up to your preferences. For reference, the options list can be found in this documentation page.

A lint script in the package.json will put those two in action.

{
  "scripts": {
    "lint": "eslint --ext .ts --ext .js src"
  }
}

Create our first web component with Lit

Install the Lit Element dependency (from Lit getting started):

npm install --save-dev lit

And let’s create our first component with a simple button which supports two themes:

// src/components/super-button.ts

import {
  css,
  html,
  LitElement,
  PropertyDeclaration,
  PropertyDeclarations,
  TemplateResult,
} from "lit";
import { customElement } from "lit/decorators.js";
import { ClassInfo, classMap } from "lit/directives/class-map.js";

export type SuperButtonTheme = "primary" | "danger";

interface SuperButtonPropsDeclaration extends PropertyDeclarations {
  theme: PropertyDeclaration;
}

// customElement decorator defines and register the custom element:
// https://lit.dev/docs/api/decorators/#customElement
@customElement("super-button")
export class SuperButton extends LitElement {
  /** Button theme, default to "primary" */
  theme!: SuperButtonTheme;

  constructor() {
    super();

    this.theme = "primary";
  }

  // some basic button styling to get started.
  static styles = css`
    :host {
      display: inline-block;
    }

    .super-button {
      align-items: center;
      border-radius: 8px;
      display: flex;
      flex-direction: row;
      padding: 8px 16px;
      width: 100%;
    }

    .super-button.primary {
      background-color: rebeccapurple;
      color: white;
    }

    .super-button.danger {
      background-color: crimson;
      color: white;
    }
  `;

  /**
   * List of button properties that can be defined by an attribute
   *
   * TS decorators cannot be used due to a bug which impacts Storybook.
   * If you skip the Storybook part, using TS decorators is totally fine.
   *
   * Issue mentioned in Storybook kitchen sink:
   * https://github.com/storybookjs/storybook/blob/next/examples/web-components-kitchen-sink/src/components/sb-button.ts#L57
   * Related Lit property issue:
   * https://github.com/Polymer/lit-html/issues/1476
   *
   * @example
   *  <super-button theme="danger">Caution!</super-button>
   *
   * @see StaticProperties https://lit.dev/docs/components/properties/#declaring-properties-in-a-static-properties-field
   * @see PropertyOptions https://lit.dev/docs/components/properties/#property-options
   */
  static get properties(): SuperButtonPropsDeclaration {
    return {
      theme: { type: String },
    };
  }

  /**
   * List of CSS classes to apply to a single elements thanks the built-in
   * `classMap` directive
   *
   * @see classMap https://lit.dev/docs/templates/directives/#classmap
   */
  get cssClasses(): ClassInfo {
    return { "super-button": true, [this.theme]: true };
  }

  render(): TemplateResult {
    return html`<button class=${classMap(this.cssClasses)}>
      <slot></slot>
    </button>`;
  }
}

Nice! We now have a component but it would be even better if we could see it, right? Obviously, we could build a web page, import our component to see how it renders but when it comes to render a specific component in an isolated manner, why not use a tool specialized in that? Time for Storybook to take the stage!

Visualise with Storybook

Setup

Following the installation guide, Storybook provides a convenient helper to add Storybook and configure it:

# At the time of writing this article, Storybook 8.1.1 is installed.
npx storybook@latest init

At this point, the Storybook helper should detect:

  • The framework (web-components), with the lit dependencty
  • The package manager

Alternatively, if you prefer having more control, the documentation provides the different parameters for the initialisation helper:

npx storybook@latest init \
  # Define the framework used to build our components
  --type web_components \
  # Enforce a package manager
  --package-manager=npm

This article hasn’t defined a builder yet. Webpack being the default builder for Storybook, we will pick Webpack here.

Check that Storybook is properly installed by starting it:

npm run storybook

While TypeScript is all set up by the Storybook helper, the path alias has yet to be added. To let Storybook know how to resolve the path alias, the webpack configuration has to be extended in the .storybook/main.js file:

// .storybook/main.js

const path = require("path");

module.exports = {
  // Only .mdx and .ts stories will be used so other extensions are not required
  stories: ["../src/**/*.stories.@(mdx|ts)"],
  addons: ["@storybook/addon-links", "@storybook/addon-essentials"],
  core: {
    builder: "webpack5",
  },

  webpackFinal: async (config, { configType }) => {
    config.resolve = {
      ...config.resolve,
      alias: {
        ...config.resolve.alias,
        "@wcl-main": path.resolve(__dirname, "../src"),
      },
    };

    return config;
  },
};

Gist: wcl-main_sb-main.js

Create a story

Time to write our first story! Feel free to delete or keep Storybook generated examples, usually created under the src/stories folder.

// src/components/super-button.stories.ts

import { html } from "lit";
import { Meta, Story } from "@storybook/web-components";

// Path alias is not useful here but ensures that aliases work in Storybook
import "@wcl-main/components/super-button";
import { SuperButtonTheme } from "@wcl-main/components/super-button";

interface SuperButtonStoryAttrs {
  content: string;
  theme: SuperButtonTheme;
  onClick: () => void;
}

export default {
  title: `WCL/components/SuperButton`,
  argTypes: {
    content: { control: "text" },
    theme: { control: "select", options: ["primary", "danger"] },
    onClick: { action: "onClick" },
  },
  args: { content: "My super button!", theme: "primary" },
} as Meta;

const Template: Story<SuperButtonStoryAttrs> = ({
  content,
  theme,
  onClick,
}) => html`<super-button .theme=${theme} @click=${onClick}
  >${content}</super-button
>`;

export const Primary = Template.bind({});
Primary.args = {
  theme: "primary",
};

export const Danger = Template.bind({});
Danger.args = {
  theme: "danger",
};

Gist: wcl-main_super-button_story.ts

Start storybook to see how it renders.

Webpack 5

Webpack 5 support is available from Storybook 6.2 (Release note here). Based on their blog article on Webpack 5 support, the instructions can be found on this gist.

It’s available…so let’s try it out!

# Add Webpack 5:
# --save-exact: to ensure exactly version is currently installed without checking
#               the package-lock.json. Not really relevant here but force of habit...
# --force     : to override the peer dependency from @storybook/builder-webpack4
npm install --save-dev --save-exact --force webpack@^5.0.0

# Add some Storybook Webpack 5 utilities
npm install --save-dev @storybook/builder-webpack5 @storybook/manager-webpack5

To avoid Webpack version conflict as by default Storybook dependencies expect Webpack 4 as a peer dependency, install Webpack 5 before initialising storybook. Otherwise, npm install --force might be required

Update .storybook/main.js:

module.exports = {
  // ... existing config
  core: {
    builder: "webpack5",
  },
};

Ensure that nothing got broken in the way by restarting Storybook. The choice of using Webpack 4 or Webpack 5 will be important when it comes to dependencies version related to SASS.

Style with SCSS + PostCSS

In a real world library, it is not recommended to style component manually one by one. It opens the door to styling inconsistency and redundancy. Of course, it is possible to factorize common styling with the css template literal function (for example in the Kor component library) but as suggested in the introduction, my personal preferences goes with SCSS.

Convert styling to SCSS

Before anything, let’s make TypeScript happy with:

// shims.d.ts

declare module "*.scss" {
  // Some loader can directly convert the SCSS file into a CSSResultGroup and
  // make it ready-to-use by Lit components but let's keep things simple.
  // Clever tricks can wait.
  const styling: string;
  export default styling;
}

We can now extract our styling into a dedicated .scss file:

// src/components/super-button.scss

$rhythm: 8px;

:host {
  display: inline-block;
}

.super-button {
  align-items: center;
  border: 1px solid #23232339;
  border-radius: $rhythm;
  display: flex;
  flex-direction: row;
  overflow: hidden hidden;
  padding: $rhythm 2 * $rhythm;
  width: 100%;

  // Enjoying a bit of nesting here
  &.primary {
    // Let's have some CSS custom properties for more flexibility
    background-color: var(--wcl-primary, rebeccapurple);
    color: white;
  }

  &.danger {
    background-color: var(--wcl-danger, crimson);
    color: white;
  }
}

Gist: wcl-main_super-button.scss

Note: the border-color: #23232339 (alpha hex notation) is added to ensure that PostCSS works properly. At the time of writing this article, the feature is at stage 2. PostCSS configuration is detailed in the next section.

And import this file into our component:

// src/components/super-button.ts

import { css, unsafeCSS } from "lit";
// ...
// Path alias is not useful here but ensure that aliases are properly configured
// when we will build our library.
import styling from "@wcl-main/components/super-button.scss";
// ...

@customElement("super-button")
export class SuperButton extends LitElement {
  // ...
  // Remove old styling
  static styles = css`
    ${unsafeCSS(styling)}
  `;
  // ...
}

Gist: wcl-main_super-button-v2.ts

The different styling loader, css-loader for Webpack (Storybook) and PostCSS plugin for Rollup (build) provides styling in raw text format so it has to be converted into a Lit Element friendly format via unsafeCSS.

Some plugin or loader can handle the conversion but, with Storybook and Rollup, making the code compatible with both Webpack loaders and Rollup plugins, as well as taking into account SCSS and PostCSS processing, is not worth the effort, from my perspective, when a single can meet our requirement.

Update Storybook configuration

Storybook does not understand SCSS by default and this is where the dependency maze begins. Depending on the webpack version, the webpack loaders need to meet specific version requirements:

Dependency Webpack 4 Webpack 5 Comment
webpack 4.x 5.x
- - - -
@storybook/* < 6.2 >= 6.2 Webpack5 is not enabled by default and additional utilities are required
sass - - No restriction
sass-loader <= 10.x >= 11.x
postcss - - No restriction (postcss-loader 4.3.0 accepts both PostCSS 7 and 8)
postcss-loader <= 4.x >= 5.x postcss-loader 5.x+ accepts both PostCSS 7 and PostCSS 8
postcss-preset-env - - Requires PostCSS 7
# Install SASS and its webpack loader
npm install --save-dev sass sass-loader
# Install PostCSS, its webpack loader and a configuration preset
npm install --save-dev postcss postcss-loader postcss-preset-env

Add a webpack rule for SCSS files:

// .storybook/main.js
module.exports = {
  // ...

  webpackFinal: async (config, { configType }) => {
    // ...

    config.module.rules.push({
      test: /\.scss$/,
      use: [
        { loader: "css-loader" },
        { loader: "postcss-loader" },
        { loader: "sass-loader" },
      ],
    });

    return config;
  },
};

Pick a stage for postcss-preset-env (more information on the repository):

// postcss.config.js

// Remove the linting ignore comments if you don't need it

/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable no-undef */
const postcssPresetEnv = require("postcss-preset-env");

module.exports = {
  plugins: [postcssPresetEnv({ stage: 2 })],
};

Finally check that Storybook still runs and if SCSS and PostCSS work as expected.

For debugging purpose, npm run storybook -- --debug-webpack is an useful option to check what is the exact webpack configuration loaded by Storybook.

Note on Storybook PostCSS: Storybook recommends using @storybook/addon-postcss but this addon only handles CSS files. On the other hand, @storybook/preset-scss is a one-liner support of SCSS files but does not allow PostCSS to get in the loader configuration. For references:

Style linting

For those who want to go further and check style linting, stylelint is here for you:

npm install --save-dev stylelint stylelint-config-sass-guidelines stylelint-order

Create a .stylelintrc.json configuration file:

{
  "extends": "stylelint-config-sass-guidelines",
  "plugins": ["stylelint-order"]
}

Add the style lint:style linting script in the package.json:

{
  "scripts": {
    "lint": "stylelint src"
  }
}

Relevant links are:

Build with Rollup

So far so good: we can create and view our web components. However, it would be sad if they could not be used in some application. Before getting into publishing, we have to “build” our library.

Install dependencies

As recommended by the Lit documentation, Rollup will be our bundler. For starters, a bunch of dependencies have to be installed:

# Install rollup
npm install --save-dev rollup

# To support path aliases
npm install --save-dev @rollup/plugin-alias

# To resolve import
npm install --save-dev @rollup/plugin-node-resolve

# To handle SCSS and PostCSS at the same time
npm install --save-dev rollup-plugin-postcss
# Or for PostCSS 7 users:
npm install --save-dev rollup-plugin-postcss@^3

# To handle typescript
npm install --save-dev @rollup/plugin-typescript
# Alternatively:
npm install --save-dev rollup-plugin-typescript2

# To provide some nice report :)
npm install --save-dev rollup-plugin-summary

# To clean the output directory before each build
npm install --save-dev rimraf

Gist

Note: if your web components library lives inside a mono-repository, I highly recommend using rollup-plugin-typescript2 due to issue like rollup/plugins#287

Define Rollup configuration

A simple rollup.config.js configuration is required. The configuration example is not optimal but should do the job and allow us checking if the output is as expected:

  • SCSS and PostCSS are properly working
  • TypeScript code is transpiled to the correct ES version
// rollup.config.js

import { defineConfig } from "rollup";

import alias from "@rollup/plugin-alias";
import resolve from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import summary from "rollup-plugin-summary";
import postcss from "rollup-plugin-postcss";

// ----------------------------------------------------------------------------

export default defineConfig({
  // Re-export all components from here
  input: ["src/index.ts"],

  // Build in ES Modules syntax
  output: [{ format: "es", dir: "dist" }],

  plugins: [
    // Define path aliases
    alias({
      entries: [{ find: /^@wcl-main\/(.*)$/, replacement: "src/$1" }],
    }),

    // Resolve node modules
    resolve(),

    postcss({
      // Define loader to use
      use: ["sass"],
      // Override the default extensions set
      extensions: [".scss"],
      inject: false,
      // Don't extract styling into dedicated files
      extract: false,
      minimize: false,
      // PostCSS configuration file is then shared between Rollup and Storybook
      config: "postcss.config.js",
    }),

    typescript(),

    // Print bundle summary
    summary(),
  ],

  preserveEntrySignatures: "strict",
});

Gist: wcl-main_rollup.js

Note that Rollup configuration can be written in ES Modules syntax.

The configuration declares a index.ts which is currently missing:

// src/index.ts

// Our super button is feeling quite lonely at the moment, please give
// him some friends!
export * from "./components/super-button";

Gist: wcl-main_components-index.ts

Add build script

Finally, the last step is to create the build script:

{
  "scripts": {
    "build": "rimraf dist/ && rollup --config rollup.config.js"
  }
}

Building with npm run build should now provide a nice output in dist/ folder \o/

Publish on NPM

For the sake of completeness, let me add a quick paragraph regarding publishing the library on NPM. This section has rough coverage. I am working on another article dedicated to library build and distribution.

Prepare the library for publishing

A NPM account is required to publish packages on the NPM registry. Please create an account if you don’t have one yet.

Update the package.json:

{
  "name": "<your package name>",
  "version": "<package version>",
  "description": "<package short description>",
  "author": "<your name>",
  "license": "<license>",
  // Or whatever main file you have defined in the Rollup configuration
  "main": "dist/index.js",
  // Let's add everything at the moment
  "files": ["dist/", "src/"]
}

Gist: wcl-main_publish-package.jsonc

Publish the library

  • Login on NPM with npm login. An email will be requested. To check if you are properly logged in, npm whoami will tell you.
  • npm publish --dry-run is useful to check that all the files you want to publish are properly here
  • Publish simply with npm publish. If your package name is scoped, i.e. it looks like @<scope>/<package name>, the --access=public option is required unless you have a paid NPM account
  • Package info can be checked with npm info

Check the CDN distribution

For a quick check of your package distribution, https://unpkg.com will help you by providing a CDN distribution of your NPM package.

The CDN distribution of the package can be checked via https://unpkg.com/browse/<package name>@version/. For example: https://unpkg.com/browse/@al-un/[email protected]/.

Navigating through the browsing screen, the dist/index.js URL reveals to be https://unpkg.com/@al-un/[email protected]/dist/index.js so let’s create a simple HTML page to test it out:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <script
      type="module"
      src="https://unpkg.com/@al-un/[email protected]/dist/index.js"
    ></script>
  </head>
  <body>
    <div>
      <h2>A normal super button</h2>
      <super-button theme="primary">A super primary button!</super-button>
    </div>

    <div style="--wcl-primary: limegreen">
      <h2>A super button with limegreen custom primary color</h2>
      <super-button theme="primary">Another super primary button!</super-button>
    </div>
  </body>
</html

Open the page in your favourite browser.


And that’s it to get started with web components libraries!

Thank you for reading and happy library coding!