Skip to content

CEM Analyzer Plugin

A CEM (custom-elements.json) is a standard description of the elements a package defines (their tags, attributes, properties, events and slots) so tooling can read your components without executing them. Read more at custom-elements-manifest.open-wc.org for the analyzer and its plugin API, or the schema and specification for the file format itself.

The CEM Analyzer (@custom-elements-manifest/analyzer), by default, will read static props as one static field and emits no attributes for it. This causes tooling like Storybook and code editors autocomplete to have nothing to read.

Using our web-component-base/cem-plugin, the CEM Analyzer will expand each prop into a typed manifest attribute and matching public field. The steps below will guide you through installing the CEM Analyzer and configuring it so that tooling can work with your wcb custom elements.

Terminal window
npm i -D @custom-elements-manifest/analyzer
custom-elements-manifest.config.mjs
import { wcbStaticProps } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.{js,ts}'],
outdir: '.',
plugins: [wcbStaticProps()],
}

Then run the analyzer:

Terminal window
npx cem analyze

Given a component:

src/cozy-button.ts
import { WebComponent, html } from 'web-component-base'
type CozyButtonProps = {
variant: 'primary' | 'ghost'
disabled: boolean
maxCount: number
}
export class CozyButton extends WebComponent<CozyButtonProps> {
static props: CozyButtonProps = {
variant: 'primary',
disabled: false,
maxCount: 3,
}
static shadowRootInit = { mode: 'open' }
static styles = ':host { display: inline-block }'
get template() {
return html`<button>${this.props.variant}</button>`
}
}
customElements.define('cozy-button', CozyButton)

custom-elements.json gains a typed attribute and a matching public field per prop:

attributetypefielddefault
variantstringvariant'primary'
disabledbooleandisabledfalse
max-countnumbermaxCount3

…and props, shadowRootInit, styles, strictProps, observedAttributes and template are stripped from the public surface.

Two details worth knowing:

  • Types come from the default literal: true/falseboolean, numeric → number, object/array → object, everything else → string. The TypeScript annotation is not consulted, so variant’s union still lands in the manifest as string.
  • Attribute names come from wcb’s own getKebabCase, the same function observedAttributes uses, so manifest names can’t drift from what the component actually observes.

Storybook’s web-components renderer builds autodocs and controls from a Custom Elements Manifest.

.storybook/preview.js
import { setCustomElementsManifest } from '@storybook/web-components-vite'
import manifest from '../custom-elements.json'
setCustomElementsManifest(manifest)
export default { tags: ['autodocs'] }

Bind a story to the tag name and Storybook infers the rest, giving a text field for variant, a toggle for disabled, a number input for maxCount:

cozy-button.stories.js
import { html } from 'lit'
import '../src/cozy-button.ts'
export default {
title: 'Cozy/Button',
component: 'cozy-button', // ← no argTypes needed
render: ({ variant, disabled, maxCount }) => html`
<cozy-button
variant=${variant}
?disabled=${disabled}
max-count=${maxCount}
></cozy-button>
`,
}
export const Default = {
args: { variant: 'primary', disabled: false, maxCount: 3 },
}

For a complete working setup, see storybook/ in the wcb repo, which runs this configuration against the demo components.

Once custom-elements.json exists, editors can offer tag-name and attribute autocomplete for your components, driven by the same static props the plugin reads, so the hints can’t drift from the code.

First, declare the manifest file in your package.json. Route 2’s language server discovers it through this field, and it’s the ecosystem convention other manifest-driven tools will look for:

{
"customElements": "custom-elements.json"
}

VS Code’s built-in HTML language service reads its own custom data format. A second analyzer plugin converts the manifest into it, so both files come out of one cem analyze run:

Terminal window
npm i -D cem-plugin-vs-code-custom-data-generator
custom-elements-manifest.config.mjs
import { wcbStaticProps } from 'web-component-base/cem-plugin'
import { generateCustomData } from 'cem-plugin-vs-code-custom-data-generator'
export default {
globs: ['src/**/*.{js,ts}'],
outdir: '.',
plugins: [wcbStaticProps(), generateCustomData()],
}
.vscode/settings.json
{
"html.customData": ["./vscode.html-custom-data.json"]
}

Restart VS Code and <cozy- completes in HTML files, with variant / disabled / max-count offered as attributes and their types and defaults on hover.

The catch: html.customData only applies to .html files. wcb components author their markup in html tagged templates inside .js / .ts, and those do not go through the HTML language service. This route helps whoever writes plain HTML pages against your components. It will not light up inside your own templates.

Route 2: a language server extension, for tagged templates

Section titled “Route 2: a language server extension, for tagged templates”

To get the same completions inside tagged templates, you need an extension that understands them. The most current option is the Custom Elements Manifest Language Server (pwrs.cem-language-server-vscode). It autocompletes tag names and attributes inside template literals in both JS and TS, adds hover documentation for attributes and defaults, and discovers the manifest through the customElements field above, no .vscode/settings.json needed.

Two alternatives, both worth knowing the state of:

  • wc-toolkit/wc-language-server: VS Code and JetBrains, also manifest-driven. Self-described as alpha and experimental.
  • Matsuuu.custom-elements-language-server-project: the one you’ll find most often in older write-ups. It is alpha, and its repository was archived in January 2026, so prefer one of the two above.

Ship the manifest with a package: distPaths()

Section titled “Ship the manifest with a package: distPaths()”

Publishing your components to npm? The analyzer stamps each module’s path with the exact file it scanned — so with globs: ['src/**/*.ts'] every path in custom-elements.json is a .ts source file. Most packages publish only their built dist/ output (and can’t import a .ts at runtime anyway), so a consumer reading that manifest resolves paths that aren’t in the tarball.

Add distPaths() after wcbStaticProps() to rewrite those paths to the built output. Zero-config it maps src/dist/ and TypeScript extensions to their emitted JS form (.ts.js, .mts.mjs, .cts.cjs). Other extensions pass through, so a .js source only has its directory swapped:

custom-elements-manifest.config.mjs
import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin'
export default {
globs: ['src/**/*.ts'],
outdir: '.',
plugins: [wcbStaticProps(), distPaths()],
}

For a different layout, override rootDir / outDir: distPaths({ rootDir: 'lib', outDir: 'dist/esm' }). Run cem analyze after your build so the dist/ files it points at exist. See the API reference for the ext extension remap. Not publishing the manifest — e.g. a local Storybook or editor setup over your own source — needs none of this.