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.
Install
Section titled “Install”npm i -D @custom-elements-manifest/analyzerConfigure
Section titled “Configure”import { wcbStaticProps } from 'web-component-base/cem-plugin'
export default { globs: ['src/**/*.{js,ts}'], outdir: '.', plugins: [wcbStaticProps()],}Then run the analyzer:
npx cem analyzeWhat it produces
Section titled “What it produces”Given a component:
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:
| attribute | type | field | default |
|---|---|---|---|
variant | string | variant | 'primary' |
disabled | boolean | disabled | false |
max-count | number | maxCount | 3 |
…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/false→boolean, numeric →number, object/array →object, everything else →string. The TypeScript annotation is not consulted, sovariant’s union still lands in the manifest asstring. - Attribute names come from wcb’s own
getKebabCase, the same functionobservedAttributesuses, so manifest names can’t drift from what the component actually observes.
Storybook
Section titled “Storybook”Storybook’s web-components renderer builds autodocs and controls from a Custom Elements Manifest.
Wire it into Storybook
Section titled “Wire it into Storybook”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:
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.
Code editors
Section titled “Code editors”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"}Route 1: native VS Code, no extension
Section titled “Route 1: native VS Code, no extension”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:
npm i -D cem-plugin-vs-code-custom-data-generatorimport { 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()],}{ "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:
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.