The Missing Link for Web Components

Florian Geierstanger Florian Geierstanger on

Last year I was mainly working in a component library. I noticed that we had a lot of duplicated code.

Every component defines its properties, events and slots, their types, whether they are required or not, plus a lot of JSDoc annotations. This is repeated for Storybook, to inform the controls of the Storybook UI, for example to switch from the primary to the secondary variant of a button.

Then we need a template, where you wire these controls to the attributes of the component. A similar template is present in both unit and end-to-end tests. And another template for Figma Code Connect, which maps a component in Figma to the actual web component code. Naturally this can get out of sync far too easily. And since the codebase had already gone through a lot of hands for years, we had a lot of outdated Storybook files, properties that no longer existed but in test file templates etc. (For the particular Storybook sync problem, there was even an add-on, but since the StencilJS community is not huge, we wondered how well this would be maintained in the future).

What if there was a solution to all of the problems above?

Even better if it could be used across all web component frameworks, and thus maintained and improved by a much larger community. Enter the Custom Elements Manifest. This is the missing link to connect the tooling in a web components project, enable more automation and much improve the developer experience.

I’ll use the custom-elements-manifest-demo repository to show how to set up the different parts and how to use them.

Scaffold a New Lit project with a Button Component

First, we scaffold a new Lit project:

pnpm create vite

In the CLI dialog, pick “Select a framework: Lit”. Our example project will start with a single component: touch src/my-button/my-button.ts

import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";

@customElement("my-button")
export class MyButton extends LitElement {
  /**
   * The button variant style.
   */
  @property()
  variant: "primary" | "secondary" = "primary";

  render() {
    return html`<button part="button" class=${this.variant}>
      <slot></slot>
    </button>`;
  }

  static styles = css`
    button {
    }
  `;
}Code language: TypeScript (typescript)

The button has a default slot and a variant property that can be either primary or secondary.

Create the Manifest JSON

You can imagine the Custom Elements Manifest like a detailed table of contents for the entire project. Every component has its entry, complete with all attributes, and other meta data like default values, available options and textual descriptions.

Here’s the beginning of a custom-elements.json file about our new button:

{
  "schemaVersion": "1.0.0",
  "modules": [
    {
      "path": "src/my-button/my-button.ts",
      "declarations": [
        {
          "name": "MyButton",
          "tagName": "my-button",
          "customElement": true
          "attributes": [
            {
              "name": "variant",
              "type": { "text": "\"primary\" | \"secondary\"" },
              "default": "\"primary\"",
              "description": "The button variant style."Code language: JSON / JSON with Comments (json)

Luckily we don’t need to gather all of this information by hand. Instead, there is a tool that walks all files in a project, parses out the useful bits and compiles it to a JSON file.

First we need to install the Custom Elements Analyzer:

pnpm add -D @custom-elements-manifest/analyzer

The analyzer comes with built-in configuration for common web component frameworks, like Lit, StencilJS or Fast, but you can also roll your own. Let’s add an entry to the package.json scripts:

// package.json
  "scripts": {
+    "analyze": "cem analyze --litelement --globs \"src/**/*.ts\"",Code language: JavaScript (javascript)

Now lets run the analyzer pnpm analyze and it writes custom-elements.json to disk.

Auto-Generate the Storybook Stories File

Having the manifest, we can make our first use of it. As we will see, it can do most of the configuration part of a .stories file for us. Let’s install Storybook first: 

pnpm create storybook@latest

Then we need some initial wiring between Storybook and the manifest file. We will use a helper tool for that: 

pnpm i -D @wc-toolkit/storybook-helpers

Add the following to our existing .storybook/preview.ts:

import { setCustomElementsManifest } from "@storybook/web-components-vite";
import { setStorybookHelpersConfig } from "@wc-toolkit/storybook-helpers";
import { withActions } from 'storybook/actions/decorator';
import manifest from "../custom-elements.json" with { type: "json" };

setCustomElementsManifest(manifest); // (1)
setStorybookHelpersConfig({  hideArgRef: true, }); // (2)

const preview: Preview = {
  // existing config here
  tags: ['autodocs'], // (3)
  decorators: [withActions], // (4)
}Code language: TypeScript (typescript)

The first function call (1) makes our manifest available as a global in all stories files. The second (2) configures the helpers that we will use in the next step. Autodocs (3) adds a auto generated Docs page for each stories file, which includes a table with the component API definition and a preview for each story. The withActions decorator (4) is needed to display events that our component emits in Storybook’s Actions panel.

Now let’s create a stories file for our button:

touch src/my-button/my-button.stories.ts.
// src/my-button/my-button.stories.ts
import type { Meta, StoryObj } from "@storybook/web-components-vite";
import { getStorybookHelpers } from "@wc-toolkit/storybook-helpers";
import "./my-button";
import type { MyButton } from "./my-button";

export const { events, args, argTypes, template } =
  getStorybookHelpers<MyButton>("my-button");

const meta = {
  title: "Components/Button",
  component: "my-button",
  argTypes,
  args: { ...args, "default-slot": "Click me" },
  render: (args) => template(args),
  parameters: { actions: { handles: events } },
} as Meta<MyButton>;

export default meta;
export const Primary: StoryObj<MyButton> = {};Code language: TypeScript (typescript)

If you are familiar with typical stories files, you will notice that this one is very succinct. We can infer events, args, argTypes and even the template from the manifest data.

Now lets run Storybook.

pnpm storybook
Screenshot of a Storybook interface displaying documentation for a custom button component, including sections for attributes and slots.

Notice how this documents the interface of the component extensively. We see the attribute and the default slot, each with description and even the options “primary” and “secondary”. Also the controls are fully functional.

Get Editor Support with the VSCode Language Server

Another application for the manifest file is editor support. First, we install the Web Components Language Server extension for VSCode. To see it in action we add a new story, where we write the template ourselves:

// src/my-button/my-button.stories.ts
// ...existing code

export const Variants: StoryObj<MyButton> = {
  render: () => `
    <my-button variant="primary">Primary</my-button>
    <my-button variant="">Secondary</my-button>
  `,
};Code language: TypeScript (typescript)

Hovering over reveals a lot of useful IntelliSense information.

A code snippet displaying the render function of a custom button component in a web development context, featuring two button instances with different variant attributes, and documentation about the component's attributes, events, and slots.

Hitting ctrl space shows the available options for the variant attribute.

Code snippet demonstrating the render function for a button component in a web development context, showcasing options for the 'variant' attribute.

Ground Copilot with the MCP server

Wouldn’t it be nice if GitHub copilot had the same information? For this, we enable the MCP server that is part of the language server extension.

// ~/Library/Application Support/Code/User/settings.json
// ...existing configuration


  "wctools.mcp.enabled": true,
Code language: JSON / JSON with Comments (json)

Now we can ask the AI agent about our web components by @-mentioning the MCP server.

A screenshot showing the description of a custom button component called 'my-button', including its attributes, properties, and events.

While this seems trivial in this example, it helps to ground the LLM and makes it less likely to hallucinate components or properties.

Ensure Components are Used Correctly with the Web Components Linter

But what if you already have a lot of code and want to make sure the components are used correctly. This is where the Web Component Linter into play. Install it with:

pnpm add @wc-toolkit/wctools
// package.json
  "scripts": {
	// ...existing scripts
+      "lint": "wctools validate"

Code language: JSON / JSON with Comments (json)

Running this…

pnpm lint

… checks the entire codebase against what is written in manifest. If my AI agent had invented a tertiary variant that does not exist <my-button variant="tertiary">Tertiary</my-button> the linter would catch it.

Error message displayed in a terminal window indicating a validation issue with a web component, specifically that 'tertiary' is not a valid value for the 'variant' attribute.

Note that the naming here is a bit confusing, there is also the CEM Validator, which looks at the manifest file itself.

Future Ideas

Looking at the future, I think there is a lot more that we could do with the Custom Elements Manifest! Component tests need a template, so why not use a helper that generates these, similar to the one for Storybook? Same for Figma Code Connect files.

Recommended Podcasts:

Learn to Work with Web Components

Leave a Reply

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

$966,000

Frontend Masters donates to open source projects through thanks.dev and Open Collective, as well as donates to non-profits like The Last Mile, Annie Canons, and Vets Who Code.