Skip to content

Distributing and Browser Support

A web component is just a JavaScript module. Publish it the same way you would any ES module package. A minimal package.json:

{
"name": "my-counter",
"version": "1.0.0",
"type": "module",
"main": "./dist/my-counter.js",
"module": "./dist/my-counter.js",
"exports": {
".": "./dist/my-counter.js"
},
"files": ["dist"],
"keywords": ["web-components", "custom-element"]
}

Consumers install it with npm install my-counter and import it once (a side-effect import is enough to register the tag):

import 'my-counter';
// <my-counter> is now usable anywhere in the document

The Custom Elements Manifest (custom-elements.json) is a machine-readable JSON file that documents every custom element in a package — its tag name, properties, attributes, events, and CSS custom properties. Tools like Storybook, IDEs, and design-system dashboards consume it for auto-complete and documentation.

Generate one with the @custom-elements-manifest/analyzer CLI:

Terminal window
npm install --save-dev @custom-elements-manifest/analyzer
npx cem analyze --globs "src/**/*.js"

A fragment of what it produces:

{
"schemaVersion": "1.0.0",
"modules": [{
"kind": "javascript-module",
"path": "src/my-counter.js",
"declarations": [{
"kind": "class",
"name": "MyCounter",
"customElement": true,
"tagName": "my-counter",
"attributes": [
{ "name": "label", "type": { "text": "string" } }
],
"members": [
{ "kind": "field", "name": "count", "type": { "text": "number" } }
]
}]
}]
}

Follow semantic versioning. Breaking changes in a custom element are:

  • Removing or renaming an attribute, property, or event.
  • Changing the element’s tag name.
  • Changing the shape of a custom event’s detail object.

Non-breaking changes include adding new optional attributes, new CSS custom properties, or new public methods.

As of 2026, Custom Elements v1, Shadow DOM v1, and HTML Templates are supported in every major browser — Chrome, Firefox, Safari, and Edge (all current and most previous versions). The “Baseline Widely Available” badge applies to all three pillars.

The one exception: customized built-ins (elements that extend a built-in like HTMLButtonElement with is="...") are not supported in Safari. Prefer autonomous custom elements (extends HTMLElement) for maximum portability.

Lit isn’t the only compiler/library option in this space. Polymer (Google’s original Web Components library) is deprecated — its own repository tells new projects to use LitElement instead. Stencil (the compiler behind Ionic Framework’s components) and Microsoft’s FAST (FAST Element + FAST Foundation) are both still actively maintained as separate projects — note that Microsoft’s newer Fluent UI web components (@fluentui/web-components) ship as their own package, distinct from FAST Foundation, so check which one a given tutorial is actually referring to before copying code.

Lit itself doesn’t publish a fixed minimum-version table. Its own docs describe testing “modern browsers” — the current version of Chromium, Safari, and Firefox plus two major versions prior for Chromium/Safari, and Firefox’s Extended Support Release — and explicitly dropping support for legacy browsers like Internet Explorer 11, which never supported web components. (lit.dev/docs/tools/requirements) For production apps that must reach further back, the @webcomponents/polyfills package fills gaps.

The demo below shows a simple distributable badge element that illustrates the “published package feel” — exactly what a consumer would see after installing your component from npm.

OptionBenefitCost
Publish as an unbundled ES module and declare Lit as a peerDependenciesThe consumer’s bundler can fully dedupe and tree-shake Lit, reducing the chance of loading it twiceDepends on the consumer having a bundler that resolves ESM correctly
Bundle/minify Lit into your own package fileWorks immediately even for a consumer with no build stepRisks loading multiple copies of the Lit runtime when a single page uses components from several vendors, bloating the bundle
  • Bundling the Lit runtime into every published component instead of declaring it as a shared dependency — if a page uses components from multiple vendors, users unknowingly load Lit multiple times, bloating the bundle unnecessarily.
  • Publishing only a minified/bundled file with no unbundled ESM option — this destroys the consumer’s bundler’s ability to tree-shake and code-split.
  • Forgetting to document browser support or polyfill requirements in the README — consumers who need to support older browsers won’t know they have to install @webcomponents/polyfills themselves.

💡 Real-world examples

Home Assistant — publishes and maintains many custom elements that share Lit as a single dependency across the whole app, instead of bundling Lit separately into each element. (home-assistant/frontend)

Google Material Web — publishes each component as its own unbundled ES module, so consumers can import and tree-shake only the parts they use. (material-components/material-web)

What is the purpose of the Custom Elements Manifest file?
Which type of custom element is NOT supported in Safari?
What does importing 'my-counter' (a side-effect import) do?