# web-component-base (wcb) > A zero-dependency base class for building reactive custom elements. This file is the complete English documentation from https://webcomponent.io, concatenated as plain Markdown. #### Clean. Skip repetitive things when writing custom elements #### Tiny. Only the bare minimum code to boost productivity #### Easy. Sensible life-cycle hooks that you understand and remember #### Familiar. Declarative templates for DOM manipulation & event handlers ### Showcase Components built with wcb, each on a live demo page of its own. #### [``](https://mastodon-content.webcomponent.io) Progressively enhances a Mastodon status: rewrites hashtag links and marks hashtag bars #### [``](https://status-indicator.webcomponent.io) Colored circles that can pulse # Guides ## Getting Started Source: https://webcomponent.io/getting-started/ Scaffold a component with npm create wcb, or install web-component-base and write your first reactive custom element by hand. **Web Component Base (WCB)** is a zero-dependency, tiny JS base class for creating reactive [custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_Components/Using_custom_elements). When you extend the WebComponent class for your custom element, you only have to define the template and properties. Any change in an observed property's value will automatically cause the component UI to render. ### Quick start **Prerequisites:** [Node.js](https://nodejs.org) (current LTS), which includes npm. The fastest way to try wcb is to scaffold a new component: ```bash npm create wcb@latest # or name it directly: npm create wcb@latest my-button ``` It sets up a publishable custom element, ready to develop: - **A starter component** in TypeScript using the [`static props`](https://webcomponent.io/prop-access/) convention. The class, tag, and file names are derived from your project name (`my-button` → `MyButton` / ``), so there are no rename-me TODOs - **A Vite setup**: a dev server with a demo page, plus a library build (`npm run build:lib`) that emits ESM + UMD bundles and `.d.ts` types, with `web-component-base` as a peerDependency - **`custom-elements.json` generation**: the CEM analyzer configured with wcb's plugin, an `npm run analyze` script, the `customElements` field in `package.json`, and a `prepack` hook so the manifest ships inside the published package Run the demo page to see the starter component render: ```bash cd my-button npm install npm run dev ``` Vite prints a local URL. Open it to see `` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save. See the [CEM Analyzer Plugin guide](https://webcomponent.io/cem-plugin/) for more details on the manifest and how to use it with Storybook and code editors. Prefer starting on GitHub? The [`ayo-run/web-component`](https://github.com/ayo-run/web-component) template repository serves the same purpose via "Use this template". ### Install via npm To add wcb to an existing project instead of scaffolding a new one, install it from [npm](https://npmx.dev/package/web-component-base): ```bash npm i web-component-base ``` The package ships standard ECMAScript Modules (ESM), so it works with bundlers as well as import maps pointing into `node_modules/web-component-base`. Then import the base class with the bare package name: ```js import { WebComponent } from 'web-component-base' ``` Continue with [Usage](https://webcomponent.io/usage) to define your first component. :::tip[Prefer a CDN?] You can also import wcb straight from a CDN in vanilla JS or HTML files. See the [CodePen examples](https://webcomponent.io/examples/#codepen-examples) for working setups. ::: ### Getting help Open a [GitHub issue](https://github.com/ayo-run/wcb/issues/new) or [discussion](https://github.com/ayo-run/wcb/discussions) for problems or requests. You can also submit a ticket on [SourceHut](https://todo.sr.ht/~ayoayco/wcb) or via [Email](mailto:~ayoayco/wcb@todo.sr.ht). ## Why would anyone use WCB? Source: https://webcomponent.io/why/ Five reasons to reach for wcb — the cheapest runtime reactivity, no build step, HTML-native attributes — and when it is the wrong choice. The `WebComponent` base class gives a full component development experience at the lightest weight possible: the minimum code to boost productivity. WCB exists for five reasons: 1. **It is the cheapest runtime reactivity you can buy.** Smallest footprint for a full authoring experience: declarative templates, typed prop⇄attribute sync, lifecycle hooks, and state-preserving re-renders. Lit and FAST cost 2x-4.7x more on the wire (brotli-compressed). If your budget is "a component on a mostly-static page", WCB fits where Lit and FAST are the heaviest thing on the wire. 2. **Zero tooling, genuinely.** No compiler, no decorators, no build step: one `import` from a CDN in a ` ``` ## Examples Source: https://webcomponent.io/examples/ A gallery of runnable wcb demos with their source: boolean props, attribute converters, templating, shadow DOM, lifecycle and more. ### Live demo gallery Every example below runs as a standalone page at [demo.webcomponent.io ↗](https://demo.webcomponent.io/), a live gallery with the source alongside each demo. | Demo | Shows | | ---- | ----- | | [Boolean props ↗](https://demo.webcomponent.io/examples/boolean-props/) | presence/absence reflection, `toggleAttribute`, `[flag]` selectors | | [Custom attribute converters ↗](https://demo.webcomponent.io/examples/attribute-converters/) | `toAttribute`/`fromAttribute` for `Date` and array props | | [Props blueprint ↗](https://demo.webcomponent.io/examples/props-blueprint/) | `static props` as the single source of defaults and types | | [Prop type enforcement ↗](https://demo.webcomponent.io/examples/strict-props/) | `static strictProps` and the log-not-throw default | | [Compile-time prop types ↗](https://demo.webcomponent.io/examples/typed-props/) | typing `this.props` in TypeScript | | [Typed props ↗](https://demo.webcomponent.io/examples/type-restore/) | attribute round-trips restoring the declared type | | [Templating ↗](https://demo.webcomponent.io/examples/templating/) | string vs `html` tagged-template rendering | | [Render reconciliation ↗](https://demo.webcomponent.io/examples/render-reconciliation/) | in-place patching preserving focus, caret and input state | | [Style objects ↗](https://demo.webcomponent.io/examples/style-objects/) | calculated and conditional styles via the `style` prop | | [Shadow DOM ↗](https://demo.webcomponent.io/examples/use-shadow/) | `static shadowRootInit` | | [Constructable styles ↗](https://demo.webcomponent.io/examples/constructed-styles/) | `static styles`, including composing several sheets | | [Lifecycle order ↗](https://demo.webcomponent.io/examples/lifecycle-order/) | each hook logged as it fires | | [Attribute lifecycle ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) | how attribute changes drive the hooks | | [onChanges payload ↗](https://demo.webcomponent.io/examples/on-changes/) | camelCase `property` vs kebab-case `attribute` | | [Just the parts ↗](https://demo.webcomponent.io/examples/just-parts/) | using `html`/`createElement` without the base class | | [Kitchen sink ↗](https://demo.webcomponent.io/examples/demo/) | several features together | | [Single-file pen ↗](https://demo.webcomponent.io/examples/pens/counter-toggle.html) | counter and toggle in one HTML file | ### CodePen examples #### 1. To-Do App A simple app that allows adding / completing tasks: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/GRegyVe?editors=1010) ![To-Do App screen recording](https://raw.githubusercontent.com/ayoayco/web-component-base/main/assets/todo-app.gif) #### 2. Single HTML file Example Here is an example of using a custom element in a single .html file. ```html WC Base Test ``` #### 3. Feature Demos Some feature-specific demos: 1. [Context-Aware Post-Apocalyptic Human](https://codepen.io/ayoayco-the-styleful/pen/WNqJMNG?editors=1010) 1. [Simple reactive property](https://codepen.io/ayoayco-the-styleful/pen/ZEwoNOz?editors=1010) 1. [Counter & Toggle](https://codepen.io/ayoayco-the-styleful/pen/PoVegBK?editors=1010) 1. [Using custom templating (lit-html)](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010) 1. [Using dynamic style objects](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010) 1. [Using the Shadow DOM](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010) 1. [Using tagged templates in your vanilla custom element](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010) ## Add your demo to the showcase Source: https://webcomponent.io/showcase/ The whole path from `npm create wcb@latest` to a card on the homepage — build a component, put its demo page online, and add one entry to the showcase data file. The [docs homepage](/) features a showcase of demo cards, one per live demo of a component built with wcb. Anyone can add a new entry to link to their own demo pages. This guide walks you through the whole path: scaffolding a component, putting its demo page online, and sending the entry. If your component already runs on a public page, start at [step 3](#3-add-your-entry). ### What a card needs A card points at a demo page, and yours qualifies when: - **The page is public**, reachable over `https://`, and stays up. A project site, a GitHub Pages deployment, or one page on your own domain all count. - **The component on it is built with wcb** — it extends `WebComponent`, or uses [just the parts](https://webcomponent.io/just-parts/) (`html`, `createElement`) directly. - **It has a custom element tag name**, which becomes the card's title. One card is one element. - **The page links back to [webcomponent.io](https://webcomponent.io)** — a visible link is enough, such as "A [web-component-base](https://webcomponent.io) component" in the footer, which is what `npm create wcb@latest` already puts there. The component does not have to be published to npm, and the demo page does not have to be elaborate. Entries whose demo stops loading get removed, so hold off on listing a page you plan to take down or is not yet available publicly. ### Before you start You will need the following: - [Node.js](https://nodejs.org) (current LTS), which includes npm - [pnpm](https://pnpm.io/installation), which the docs site requires — it refuses to install under any other package manager - A GitHub account, for the pull request ### 1. Build a component Skip to step 2 if you already have one. ```sh npm create wcb@latest my-element cd my-element npm install npm run dev ``` Vite prints a local URL. `index.html` at the project root is the demo page and already renders ``; the component behind it is `src/my-element.ts`. Edit that file and the page updates as you save. [Getting Started](https://webcomponent.io/getting-started/) describes what else the scaffold sets up, and [Usage](https://webcomponent.io/usage/) covers writing the component itself. ### 2. Put the demo page online ```sh npm run build ``` That builds `index.html` and its assets into `dist/`. (`npm run build:lib` is the other one — it packages the component for npm, and produces no page.) Deploy `dist/` to any static host: [Netlify](https://docs.netlify.com/site-deploys/create-deploys/), [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site), Cloudflare Pages, or a directory on your own server. Serving from a subpath — `https://you.github.io/my-element/` rather than a domain root — needs that prefix at build time, or the page loads with no styles and no script: ```sh npm run build -- --base=/my-element/ ``` Open the deployed URL and confirm the component renders, and that the page links back to [webcomponent.io](https://webcomponent.io) — the scaffold's footer carries one. That URL is what the card links to. ### 3. Add your entry [Fork `ayo-run/wcb`](https://github.com/ayo-run/wcb/fork) on GitHub, clone your fork, and make a branch for the change. One file holds every card: ```js title="docs/src/showcase.mjs" export const showcase = { 'mastodon-content': { href: 'https://mastodon-content.webcomponent.io', description: 'Progressively enhances a Mastodon status: rewrites hashtag links and marks hashtag bars', }, 'status-indicator': { href: 'https://status-indicator.webcomponent.io', description: 'Colored circles that can pulse', }, } ``` Add your component as one more key, in alphabetical position: ```js title="docs/src/showcase.mjs" 'my-element': { href: 'https://my-element.example.com', description: 'One line on what the component does', }, ``` That is the whole change. The key is your tag name and becomes the card's title, rendered as ``; the `href` gets an external-link marker and opens in a new tab. The homepage and its three translations all render from this object, so there is no page to edit. #### The fields | Field | Required | What it is | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | _the key_ | yes | The custom element's tag name as it appears in HTML, quoted because of the hyphen. It is the card's title, and no two entries share one. | | `href` | yes | The demo page, as an absolute `https://` URL. | | `description` | yes | One line of English on what the component does — the card's body. Roughly 15 words. | | `translations` | no | The same line in other locales, keyed by locale prefix (`ja`, `zh-cn`, `tl`). A locale you leave out shows the English line. | Add a translation for any language you write: ```js title="docs/src/showcase.mjs" 'my-element': { href: 'https://my-element.example.com', description: 'One line on what the component does', translations: { ja: 'コンポーネントの説明を一行で', }, }, ``` ### 4. Preview the site From the root of your clone: ```sh pnpm install pnpm docs ``` Astro serves the site at `http://localhost:4321`. The showcase is at the bottom of the homepage, and `/ja/`, `/zh-cn/` and `/tl/` show your card with whichever description applies. ```sh pnpm test ``` The suite fails if an entry is missing a field, its `href` is not an `https://` URL, or the keys have fallen out of alphabetical order. ### 5. Open the pull request Commit the one changed file and open a pull request against [`ayo-run/wcb`](https://github.com/ayo-run/wcb): ```sh git commit -am "docs: add to the showcase" ``` The title is all the description the change needs. A reviewer checks three things: the demo page loads, the component on it is built with wcb, and the page links back to webcomponent.io. Once the pull request is merged, your card appears the next time the site is built. The repository is also mirrored on [SourceHut](https://git.sr.ht/~ayoayco/wcb) if you would rather work from there. ## template vs render() Source: https://webcomponent.io/template-vs-render/ How the read-only template getter relates to render(), when render() is called for you, and how to override it for another templating library. This mental model attempts to reduce the cognitive complexity of authoring components: 1. The `template` is a read-only property (initialized with a `get` keyword) that represents _how_ the component view is rendered. 1. There is a `render()` method that triggers a view render. 1. This `render()` method is _automatically_ called under the hood every time an attribute value changed. 1. You can _optionally_ call this `render()` method at any point to trigger a render if you need (eg, if you have private unobserved properties that need to manually trigger a render) 1. Overriding the `render()` function for handling a custom `template` is also possible. Here's an example of using `lit-html`: [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/ZEwNJBR?editors=1010) See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) for the two template kinds, and [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) for what an in-place re-render preserves: focus, caret position and an uncommitted input value all survive. ### Composing components A component's `template` can contain other components, nested as deep as you like: ```js class CounterBoard extends WebComponent { static props = { title: 'Board' } get template() { return html`

${this.props.title}

` } } ``` Each nested component **owns the DOM it renders for itself**. When an outer component re-renders, the reconciler patches the props it passes down to a nested element (that is how data flows from parent to child) but never touches the element's own children, so a nested component keeps its rendered content and any internal state even when an ancestor re-renders for an unrelated reason. Data flows down as attributes, so pass values a nested component can read back from an attribute: primitives, or objects/arrays that survive a JSON round-trip. See it live: [Nested composition demo ↗](https://demo.webcomponent.io/examples/nested-composition/). The one exception is **slot projection**: children you write _inside_ a shadow-DOM component's tag are your content, projected into its ``, so the parent keeps reconciling those. A light-DOM component, by contrast, renders over its own children, so pass data to it through attributes rather than as projected children. ## Prop Access Source: https://webcomponent.io/prop-access/ Read and write observed attributes as camelCase through this.props, with typed props, boolean semantics and custom attribute conversion. The `props` property of the `WebComponent` interface is provided for easy read/write access to a camelCase counterpart of _any_ observed attribute. ```js class HelloWorld extends WebComponent { static props = { myProp: 'World', } get template() { return html`

Hello ${this.props.myProp}

` } } ``` Assigning a value to the `props.camelCase` counterpart of an observed attribute will trigger an "attribute change" hook. For example, assigning a value like so: ``` this.props.myName = 'hello' ``` ...is like calling the following: ``` this.setAttribute('my-name','hello'); ``` Therefore, this will tell the browser that the UI needs a render if the attribute is one of the component's observed attributes we explicitly provided with `static props`; :::note The `props` property of `WebComponent` works like `HTMLElement.dataset`, except `dataset` is only for attributes prefixed with `data-`. A camelCase counterpart using `props` will give read/write access to any attribute, with or without the `data-` prefix. Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'. ::: #### Opt-in typed props in TypeScript See it live: [Compile-time prop types demo ↗](https://demo.webcomponent.io/examples/typed-props/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/) By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props. Declare the shape as a named type, pass it as the class type argument, and annotate `static props` with it in the initialization: ```ts type CozyButtonProps = { variant: 'primary' | 'ghost' disabled: boolean } class CozyButton extends WebComponent { static props: CozyButtonProps = { variant: 'primary', disabled: false, } get template() { this.props.variant // 'primary' | 'ghost' this.props.disabled // boolean this.props.notAProp // ❌ compile error: not declared this.props.disabled = 'yes' // ❌ compile error: string is not boolean this.props.variant = 'plaid' // ❌ compile error: not in the union return html`` } } ``` This annotation applies the type-check to succeeding assignments while the defaults themselves are checked against the type, so a missing key or a default outside the union is a compile error too. :::tip[Use `static strictProps` if you want runtime type guards] At runtime, assigning a different type to a prop quietly fails. Setting [`static strictProps`](https://webcomponent.io/api/web-component/#static-strictprops) to true will throw a `TypeError` when wrong types are assigned. ::: #### Boolean props Boolean props follow the HTML boolean-attribute convention, exactly like native `disabled` and `required`: **presence means `true`, absence means `false`.** See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/) ```js class FlagBox extends WebComponent { static props = { flag: false } } ``` ```html ``` Reflection works the same way in reverse. `true` sets the bare attribute, `false` removes it entirely: ```js el.props.flag = true // el.props.flag = false // ``` Because `false` is an _absent_ attribute rather than `flag="false"`, the platform's own API and CSS presence selectors both behave as you'd expect: ```js el.toggleAttribute('flag', true) // prop syncs, component re-renders ``` ```css :host([flag]) { /* matches only when the prop is actually true */ } ``` :::caution[Any present value is true] Just like native `disabled="false"` is still disabled, **`flag="false"` parses as `true`**. Presence wins, there is no special case for the literal string. Writing `setAttribute('flag', String(someBool))` therefore always yields `true`; use `toggleAttribute('flag', someBool)` instead. wcb logs a `console.warn` when it sees a boolean attribute written as `"true"` or `"false"` so this cannot fail silently. ::: Enumerated attributes like `contenteditable` and `aria-*` attributes are the exception. They are genuine strings where `"false"` is meaningful, so declare them as **string** props rather than booleans. Strings serialize literally and are never removed, so they need nothing special at runtime; in TypeScript, narrow them to the values you accept in your [props type](#typed-props-in-typescript): ```ts type ToggleProps = { ariaChecked: 'true' | 'false' } ``` :::tip[Default boolean props to false] HTML has no true-default boolean attribute: absence has to mean both "false" and "default", which only works when they coincide. Model an on-by-default flag with an inverted name (`disabled`, not `enabled`). wcb warns once per class on a `true` default, and for such a prop, removing the attribute lands on `false`, not back on the declared default. ::: #### Custom attribute conversion See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/) The rules above cover the common cases. When a prop needs its own serialization (a `Date`, a delimited list, an enumerated attribute where `"false"` is meaningful) override `toAttribute` and `fromAttribute`, and delegate everything else to `super`: ```js class EventCard extends WebComponent { static props = { when: new Date(0), title: '' } toAttribute(name, value) { if (name === 'when') return value.toISOString().slice(0, 10) return super.toAttribute(name, value) } fromAttribute(name, value) { if (name === 'when') return new Date(`${value}T00:00:00Z`) return super.fromAttribute(name, value) } } ``` ```html ``` Both take the **camelCase prop key**, matching your `static props` declaration and `onChanges`'s `property`, not the kebab-case attribute name. `toAttribute` returning **`null` removes the attribute**. That is exactly how a `false` boolean becomes an absent attribute, and it is available to any prop: ```js toAttribute(name, value) { // an empty string means "no attribute at all" for this prop return value === '' ? null : super.toAttribute(name, value) } ``` For `null` and `undefined`, the default conversion removes the attribute. That is, `this.props.selected = undefined` removes `selected` rather than writing `selected="undefined"`. A nullish default in `static props` reflects as no attribute at all. Under the hood, the prop stays nullish as WCB recognises its own reflection. ##### Conversion is only triggered on attribute changes When assigning a value to a prop, wcb does **not** check and parse the attribute back through `fromAttribute`. The prop you assigned is already the source of truth and the text form attribute could be a less precise representation. In the example above `props.when` keeps its full timestamp even though the attribute carries only the date. `render()` and `onChanges` still fire as normal. `fromAttribute` conversion is only called for attributes written from *outside* the component via markup, `setAttribute`, or `toggleAttribute`. :::caution[The declared type still applies] A prop's runtime type comes from the `typeof` of its default, and the props proxy rejects writes that violate it. A `Date`-valued prop therefore needs an actual `Date` default (`new Date(0)`), not `''`. Otherwise the prop is typed `string` and your parsed `Date` is refused. ::: ##### Handling non-serializable data types The default converters round-trip prop values through JSON, which restores numbers, booleans, and plain objects/arrays exactly. Types JSON cannot represent don't survive the trip: a `Date` comes back as a plain string, and a `Map` or `Set` collapses to `"{}"`. Such types are still first-class props. You can declare a real default of that type (see the caution above) and override the converters. The `EventCard` example above handles this by using custom conversion logic for its `Date` prop. Another way to do this is to use a library built for the job instead. You can opt to use a serializer like [devalue](https://github.com/sveltejs/devalue) as your component's dependency, which can serialize and parse `Map`, `Set`, `Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One generic pair of overrides then covers every structured prop. Here is `EventCard` rewritten with it, with an additional prop `tags` of type `Set`: ```js import { parse, stringify } from 'devalue' class EventCard extends WebComponent { static props = { when: new Date(0), tags: new Set(), title: '', } toAttribute(name, value) { // handles `when` and `tags` if (value instanceof Object) return stringify(value) return super.toAttribute(name, value) } fromAttribute(name, value) { if (this.constructor.props[name] instanceof Object) return parse(value) return super.fromAttribute(name, value) } } ``` The two guards are asymmetric on purpose: `toAttribute` sees the live value, but `fromAttribute` only ever sees a string, so it consults the declared default (using `this.constructor.props`) to decide whether the attribute is devalue-encoded. `title` misses both guards and reflects as a plain string, exactly as before. The encoded attribute is not as readable as a bespoke `when="2026-07-20"`, but it is still plain text: ```html ``` :::caution[Reassign, don't mutate] The props proxy only sees *assignments*. `this.props.tags.add('x')` mutates the Set behind the proxy's back: no reflection, no render, and the attribute goes stale. Assign a fresh instance instead: `this.props.tags = new Set(this.props.tags).add('x')`. ::: Converters cover any value with a sensible **textual form**: bespoke like an ISO date, or generic like devalue's encoding. A value with no textual form at all (a function, an element reference, a live handle like an `AbortController`) is not a converter problem: even devalue refuses it (`Cannot stringify a function`), and it doesn't belong in `static props` in the first place. Keep it as a plain class property instead. See [Unobserved properties](#unobserved-properties) below. wcb warns once per class when a declared default is a `function` or `symbol` for exactly this reason. #### Unobserved properties Everything declared in `static props` is observed and reflected: each key gets an attribute, writes trigger `render()` and `onChanges()`, and the default shows up in the DOM on first connect. That is the right contract for a component's public, DOM-facing API, and unnecessary for internal state. For state that doesn't belong in the DOM, use a plain class property. A `WebComponent` is still just a class extending `HTMLElement`, so ordinary properties work exactly as on any element and are invisible to the props machinery (no attribute, no observation, no automatic render): ```js class DataTable extends WebComponent { static props = { compact: false } // public API: rows = [] // internal state, never becomes an attribute #controller = new AbortController() // non-serializable values are fine here async onInit() { const res = await fetch('/rows', { signal: this.#controller.signal }) this.rows = await res.json() this.render() // unobserved changes render when you say so } onDestroy() { this.#controller.abort() } } ``` Because nothing watches a plain property, call [`this.render()`](https://webcomponent.io/template-vs-render/) yourself when a change to one should update the view. The rule of thumb: `static props` is for values a consumer sets from markup or styles against with attribute selectors; a class property (public or `#private`) is for everything else: large data, functions, and handles like timers or `AbortController`s that could never round-trip through an attribute anyway. If you catch yourself thinking "I don't want this showing up as an attribute", that is the signal it should be a class property, not a prop. #### Alternatives The current alternatives are using what `HTMLElement` provides out-of-the-box, which are: 1. `HTMLElement.dataset` for attributes prefixed with `data-*`. Read more about this [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset). 1. Methods for reading/writing attribute values: `setAttribute(...)` and `getAttribute(...)`; note that managing the attribute names as strings can be difficult as the code grows. ## Using the Shadow DOM Source: https://webcomponent.io/shadow-dom/ Opt a component into shadow DOM with static shadowRootInit, and what the ShadowRootInit options change. Add a static property `shadowRootInit` with object value of type `ShadowRootInit` (see [options on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options)) to opt-in to using shadow dom for the whole component. Try it now [on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/VwRYVPv?editors=1010), or see it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/) Example: ```js class ShadowElement extends WebComponent { static shadowRootInit = { mode: 'open', // can be 'open' or 'closed' } get template() { return html`

Wow!?

` } } customElements.define('shadow-element', ShadowElement) ``` ## Styling Source: https://webcomponent.io/styling/ Two ways to scope styles in a wcb component: style objects on the html template, or constructable stylesheets with a shadow root. There are two ways we can safely have scoped styles: 1. Using style objects 2. Using the Shadow DOM and constructable stylesheets It is highly recommended to use the second approach, as with it, browsers can assist more for performance. ### Using style objects When using the built-in `html` function for tagged templates, a style object of type `Partial` can be passed to any element's `style` attribute. This allows for calculated and conditional styles. Read more on style objects [on MDN](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleDeclaration). Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzXjwQ?editors=1010), or see it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/) ```js import { WebComponent } from 'https://esm.sh/web-component-base@latest' class StyledElement extends WebComponent { static props = { emphasize: false, type: 'warn', } #typeStyles = { warn: { backgroundColor: 'yellow', border: '1px solid orange', }, error: { backgroundColor: 'orange', border: '1px solid red', }, } get template() { return html`

Wow!

` } } customElements.define('styled-elements', StyledElement) ``` ### Using the Shadow DOM and Constructable Stylesheets If you [use the Shadow DOM](https://webcomponent.io/shadow-dom), you can add a `static styles` property which will be added to the `shadowRoot`'s [`adoptedStylesheets`](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptedStyleSheets). It accepts a string, a `CSSStyleSheet`, or an array of either. Try it now with this [example on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/JojmeEe?editors=1010), or see it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/) ```js class StyledElement extends WebComponent { static shadowRootInit = { mode: 'open', } static styles = ` div { background-color: yellow; border: 1px solid black; padding: 1em; p { text-decoration: underline; } } ` get template() { return html`

Wow!?

` } } customElements.define('styled-elements', StyledElement) ``` #### Composing several stylesheets Pass an array to adopt more than one sheet. They are applied **in order**, so later entries win on equal specificity. Put shared tokens or a base sheet first and per-component styles after it: ```js // tokens.js: shared across every component export const tokens = ` :host { --cozy-radius: 6px; --cozy-accent: rebeccapurple; } ` // cozy-button.js import { tokens } from './tokens.js' class CozyButton extends WebComponent { static shadowRootInit = { mode: 'open' } static styles = [ tokens, ` button { border-radius: var(--cozy-radius); background: var(--cozy-accent); } `, ] } ``` Entries may be strings or ready-made [`CSSStyleSheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet) objects, and the two can be mixed. A `CSSStyleSheet` is adopted as-is rather than re-created, so one shared instance can be constructed once and reused by every component that adopts it: ```js const base = new CSSStyleSheet() base.replaceSync(tokens) class CozyBadge extends WebComponent { static shadowRootInit = { mode: 'open' } static styles = [base, `span { font-size: 0.8em; }`] } ``` A single string keeps working exactly as before. The array form is additive. ## Using Just Some Parts Source: https://webcomponent.io/just-parts/ Use html, createElement and the other internals on a plain HTMLElement, without extending the WebComponent base class. You don't have to extend the whole base class to use some features. All internals are exposed and usable separately so you can practically build the behavior on your own classes. Here's an example of using the `html` tag template on a class that extends from vanilla `HTMLElement`... also [View on CodePen ↗](https://codepen.io/ayoayco-the-styleful/pen/bGzJQJg?editors=1010), or see it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/). ```js import { html } from 'https://esm.sh/web-component-base@latest/html' import { createElement } from 'https://esm.sh/web-component-base@latest/utils' class MyQuote extends HTMLElement { connectedCallback() { const el = createElement( html` ` ) this.appendChild(el) } } customElements.define('my-quote', MyQuote) ``` ## Life-Cycle Hooks Source: https://webcomponent.io/life-cycle-hooks/ The four lifecycle hooks — onInit, afterViewInit, onChanges and onDestroy — when each one fires and what belongs in it. Define behavior when certain events in the component's life cycle is triggered by providing hook methods See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) logs each hook as it fires, and [Attribute lifecycle demo ↗](https://demo.webcomponent.io/examples/attribute-lifecycle/) shows how attribute changes drive them. #### onInit() - Triggered when the component is connected to the DOM - Best for setting up the component ```js import { WebComponent } from 'https://esm.sh/web-component-base@latest' class ClickableText extends WebComponent { // gets called when the component is used in an HTML document onInit() { this.onclick = () => console.log('>>> click!') } get template() { return `Click me!` } } ``` #### afterViewInit() - Triggered after the view is first initialized ```js class ClickableText extends WebComponent { // gets called when the component's innerHTML is first filled afterViewInit() { const footer = this.querySelector('footer') // do stuff to footer after view is initialized } get template() { return `
Awesome site © 2023
` } } ``` #### onDestroy() - Triggered when the component is disconnected from the DOM - best for undoing any setup done in `onInit()` ```js import { WebComponent } from 'https://esm.sh/web-component-base@latest' class ClickableText extends WebComponent { clickCallback() { console.log('>>> click!') } onInit() { this.onclick = this.clickCallback } onDestroy() { console.log('>>> removing event listener') this.removeEventListener('click', this.clickCallback) } get template() { return `Click me!` } } ``` #### onChanges() - Triggered when an attribute value changed - The `changes` object cleanly separates the **property** from the **attribute**: - `property`: the **camelCase** prop key, matching how you access `props` (e.g. `myName`) - `attribute`: the **kebab-case** attribute name that changed (e.g. `my-name`) - `previousValue` / `currentValue`: the values before and after the change Use `property` to read the value straight off `props` (`this.props[property]`); use `attribute` when you need the raw attribute name. See it live: [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/) ```js import { WebComponent } from 'https://esm.sh/web-component-base@latest' class ClickableText extends WebComponent { // gets called when an attribute value changes onChanges(changes) { const { property, attribute, previousValue, currentValue } = changes console.log('>>> ', { property, attribute, previousValue, currentValue }) } get template() { return `Click me!` } } ``` :::caution[Breaking change] The `onChanges` payload now draws a clear **attribute vs. property** distinction. Previously `property` held the kebab-case _attribute_ name. It now holds the camelCase _prop_ key (matching `props` access), and the kebab-case attribute name moved to the new `attribute` field. ```js // before onChanges({ property /* 'my-name' */, previousValue, currentValue }) {} // after onChanges({ property /* 'myName' */, attribute /* 'my-name' */, previousValue, currentValue }) {} ``` If you previously read `changes.property` for the attribute name, switch to `changes.attribute`. ::: ### Upgrade ordering & the buffering guarantee Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. ``), the browser fires `attributeChangedCallback` **before** `connectedCallback`. Taken literally, that means `render()` and `onChanges()` could run before `onInit()`, so any setup you do in `onInit` (event wiring, reading external state) would not have happened yet on that first render. Test environments like happy-dom/jsdom don't reproduce this ordering, so components can pass in tests and then misbehave in a real browser. `WebComponent` removes this footgun. Attribute changes that arrive **before** the element is connected are buffered: - the **prop value is applied immediately**, so `this.props` is already correct inside `onInit()`; - the **`render()` and `onChanges()` side effects are deferred** until after `onInit()` runs. On connect, the order is always: 1. `onInit()`: `this.props` already reflects any authored attributes 2. a single `render()`: reflects all buffered props in one pass 3. `afterViewInit()` **`onChanges()` never fires before `onInit()`.** Pre-connect attribute changes are **not** replayed through `onChanges()`. The first `render()` already reflects them, so `onChanges()` is reserved for genuine post-connect changes. After the element is connected, attribute changes behave normally: each one triggers `render()` and `onChanges()` immediately. ## CEM Analyzer Plugin Source: https://webcomponent.io/cem-plugin/ Teach the Custom Elements Manifest analyzer to read static props, so Storybook and editor autocomplete can see your attributes. 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](https://custom-elements-manifest.open-wc.org/) for the analyzer and its plugin API, or the [schema and specification](https://github.com/webcomponents/custom-elements-manifest) 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. :::tip[Starting a new project?] `npm create wcb@latest` scaffolds a project with the install and configuration below already done: a starter component, this plugin and [`distPaths()`](#ship-the-manifest-with-a-package-distpaths) configured in `custom-elements-manifest.config.mjs`, an `analyze` script, and the `customElements` field set in `package.json`. Use this guide to add the manifest to an existing project, to understand what the scaffold configured, or to wire the manifest into [Storybook](#storybook) or [your editor](#code-editors) — which the scaffold leaves to you. ::: ### Install ```sh npm i -D @custom-elements-manifest/analyzer ``` ### Configure ```js // 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: ```sh npx cem analyze ``` :::caution[`cem analyze` fails by hanging, not by erroring] Run it **from the directory holding your config and a `package.json`**. Two things make it appear to do nothing, neither of which prints an error: - **No config found**: it falls back to a default glob of `**/*.{js,ts,tsx}`, which does _not_ exclude `node_modules`. In a real project that is tens of thousands of files through the TypeScript parser. - **No `package.json` in the directory**: it hangs outright. With globs scoped to your source it should finish in well under a second. ::: ### What it produces Given a component: ```ts // 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 { static props: CozyButtonProps = { variant: 'primary', disabled: false, maxCount: 3, } static shadowRootInit = { mode: 'open' } static styles = ':host { display: inline-block }' get template() { return html`` } } 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, 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 Storybook's web-components renderer builds **autodocs and controls** from a [Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest). #### Wire it into Storybook ```js // .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`: ```js // 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` `, } export const Default = { args: { variant: 'primary', disabled: false, maxCount: 3 }, } ``` :::note[The `html` in story files is lit's, not wcb's] Storybook's web-components renderer renders stories with [lit-html](https://lit.dev/docs/templates/overview/), so story files import `html` from `lit` and templates in them use **lit's binding syntax** — the `?disabled=${disabled}` prefix above is lit for "add or remove the boolean attribute" (a plain `disabled=${false}` would write `disabled="false"`, which wcb [parses as `true`](https://webcomponent.io/prop-access/#boolean-props)). None of this applies to your component: it keeps wcb's own `html` tag with no special binding prefixes, and lit is a devDependency of the Storybook setup, never shipped with the component. ::: :::tip Regenerate the manifest before starting Storybook (`cem analyze && storybook dev`). `custom-elements.json` is a build artifact, so it is usually gitignored and rebuilt on demand. ::: For a complete working setup, see [`storybook/`](https://github.com/ayo-run/wcb/tree/main/storybook) in the wcb repo, which runs this configuration against the demo components. ### 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. :::caution VS Code does **not** read `custom-elements.json` natively. Nothing happens just because the file exists. You need one of the two routes below. ::: 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: ```json { "customElements": "custom-elements.json" } ``` #### Route 1: native VS Code, no extension VS Code's built-in HTML language service reads its own [custom data](https://github.com/microsoft/vscode-custom-data) format. A second analyzer plugin converts the manifest into it, so both files come out of one `cem analyze` run: ```sh npm i -D cem-plugin-vs-code-custom-data-generator ``` ```js // 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()], } ``` ```json // .vscode/settings.json { "html.customData": ["./vscode.html-custom-data.json"] } ``` :::caution `html.customData` paths resolve from the **workspace root**, not from the settings file. If you run `cem analyze` in a subfolder, either point at `./that-folder/vscode.html-custom-data.json` or give the generator its own `outdir`, `generateCustomData({ outdir: '..' })`, so the file lands where the setting expects it. ::: Restart VS Code and ` { static props: CozyButtonProps = { variant: 'primary', disabled: false, } } ``` ### Static properties #### `static props` An object of declared prop names and their default values. ```js static props = { count: 0, label: 'hi', disabled: false } ``` It drives three things at once: - **Observed attributes.** Each key is kebab-cased, so `maxCount` observes `max-count`. - **The runtime type guard.** The `typeof` each default becomes the prop's declared type. A write of a different type is rejected (see [`strictProps`](#static-strictprops)). - **The compile-time type of `this.props`** when the object is passed as the class type argument. Defaults are copied per instance with `structuredClone`, so object and array defaults are never shared between instances. Values that cannot be cloned (functions, class instances) are kept by reference instead of throwing. On first use of each class, defaults that cannot reflect to an attribute are reported with `console.warn`: | Default | Warning | | ------------------ | --------------------------------------------------- | | a function or symbol | not reflectable: use handlers or refs instead | | `true` | boolean defaults should be `false`: invert the name | A `true` boolean default is discouraged because HTML has no true-by-default boolean attribute: absence would have to mean both "false" and "default". Name the prop for its `false` state (`disabled`, not `enabled`). See it live: [Props blueprint demo ↗](https://demo.webcomponent.io/examples/props-blueprint/) #### `static styles` CSS adopted into the shadow root as constructable stylesheet(s). ```js static shadowRootInit = { mode: 'open' } static styles = `p { color: red; }` ``` Accepts a string, a `CSSStyleSheet`, or an array mixing both. An array is adopted in declaration order, so a shared token sheet can come first and per-component rules after it. Strings are compiled to a `CSSStyleSheet` once; existing `CSSStyleSheet` instances are adopted as-is and can be shared across components. Adoption happens **once per instance**, when the element is constructed, not per render. Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root there is nothing to adopt into, and the failure is reported with `console.error` rather than thrown. See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/examples/constructed-styles/) #### `static shadowRootInit` A [`ShadowRootInit`](https://developer.mozilla.org/en-US/docs/Web/API/Element/attachShadow#options) object. Its presence is what opts the component into shadow DOM. The shadow root is attached during construction and becomes the render target. ```js static shadowRootInit = { mode: 'open' } ``` Without it the component renders into its own light DOM. See it live: [Shadow DOM demo ↗](https://demo.webcomponent.io/examples/use-shadow/) #### `static strictProps` When `true`, assigning a value whose type does not match the declared type throws a `TypeError`. ```js static strictProps = true ``` The default is to report the violation with `console.error` and skip the write, so a stray assignment cannot halt `render()` or `onChanges()`. Either way, `null` and `undefined` are always allowed. See it live: [Prop type enforcement demo ↗](https://demo.webcomponent.io/examples/strict-props/) #### `static get observedAttributes` Returns the kebab-cased keys of [`static props`](#static-props). Provided by the base class; you do not normally define it yourself. ### Instance properties #### `props` Read-only accessor returning a `Proxy` over the component's prop values. Read and write camelCase keys directly: ```js this.props.count += 1 ``` A write that changes the value reflects to the matching attribute through [`toAttribute()`](#toattributename-value), which in turn triggers a render. Assigning the value it already holds does nothing. #### `template` Read-only getter returning what the component renders. Two kinds are supported: - an [`html`](https://webcomponent.io/api/html/) tagged template: a vnode tree, reconciled in place on re-render - a **string**: assigned to the render target's `innerHTML` Both render into the same target: the shadow root when `shadowRootInit` is set, the element itself otherwise. Returning `` html`` `` (which is `undefined`) or `''` empties the rendered subtree, which is how a component renders nothing without disturbing light-DOM children a consumer slotted in. Switching between the two kinds is safe in either direction: a string render resets the vnode bookkeeping so the next vnode render rebuilds from scratch. The base implementation returns `''`. See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) #### `render()` Renders `template` into the render target. Called automatically on connect and on every prop or attribute change; you rarely call it yourself. For a vnode template, the new tree is compared against the previous one and re-render **patches the existing DOM in place**. See [Template vs Render](https://webcomponent.io/template-vs-render/) for what that preserves and the non-keyed matching caveat. ### Lifecycle hooks Override any of these; all are no-ops by default. | Hook | When it runs | | ----------------- | ----------------------------------------------------- | | `onInit()` | on connect, before the first render | | `afterViewInit()` | on connect, after the first render | | `onChanges(changes)` | after an observed attribute changes | | `onDestroy()` | when the element is disconnected | On connect the order is always: default reflection → `onInit()` → `render()` → `afterViewInit()`. Attribute-driven renders and `onChanges()` calls that the platform fires *before* connect are buffered, so `onInit()` is guaranteed to run before the first render even for attributes written in markup. `onChanges()` receives: | Field | Type | Description | | --------------- | -------- | -------------------------------------------- | | `property` | `string` | camelCase prop key, matching `props` access | | `attribute` | `string` | kebab-case attribute name that changed | | `previousValue` | `any` | value before the change | | `currentValue` | `any` | value after the change | See [Life-cycle Hooks](https://webcomponent.io/life-cycle-hooks/) for worked examples. See it live: [Lifecycle order demo ↗](https://demo.webcomponent.io/examples/lifecycle-order/) and [onChanges payload demo ↗](https://demo.webcomponent.io/examples/on-changes/) ### Attribute converters Override these to control how one prop crosses the prop/attribute boundary, and call `super` for the props you do not handle. The default conversion round-trips values through JSON. Types JSON cannot restore (`Date`, `Map`, `Set`, `URL`, class instances) need overridden converters to live on `static props`; see [Custom attribute conversion](https://webcomponent.io/prop-access/#custom-attribute-conversion) for worked examples, including the non-serializable cases. #### `toAttribute(name, value)` Converts a prop value into the attribute value that reflects it. | Parameter | Type | Description | | --------- | -------- | ---------------------------------------- | | `name` | `string` | camelCase prop key | | `value` | `any` | the prop value being reflected | | **returns** | `string \| null` | the attribute value, or `null` to remove the attribute | Returning `null` **removes** the attribute. That is how a `false` boolean becomes an absent attribute, and it works for any prop. ```js toAttribute(name, value) { if (name === 'point') return `${value.x},${value.y}` return super.toAttribute(name, value) } ``` The default conversion returns `null` for `null` and `undefined`, so assigning either to a prop **removes** its attribute instead of writing the text `"null"` / `"undefined"`, and a nullish `static props` default reflects as no attribute at all. The prop keeps the value you assigned: the removal is wcb's own reflection, so it does not trigger the declared-default reset that a removal from outside would. ```js this.props.selected = undefined // — attribute gone, prop is undefined this.props.selected = 'a' // ``` #### `fromAttribute(name, value)` Converts an attribute value into the prop value it represents, the inverse of `toAttribute()`. | Parameter | Type | Description | | --------- | -------- | --------------------------------------------- | | `name` | `string` | camelCase prop key | | `value` | `string` | the attribute value, never `null` | | **returns** | `any` | the value to store on `this.props[name]` | Only called for attributes that are **present**. Removal is handled by the declared-default reset instead, so a converter never has to handle `null`. A malformed value for a typed prop falls back to the raw string rather than throwing, so `render()` and `onChanges()` are never skipped. See it live: [Custom attribute converters demo ↗](https://demo.webcomponent.io/examples/attribute-converters/) and [Typed props demo ↗](https://demo.webcomponent.io/examples/type-restore/) ### Boolean props Boolean props follow the HTML convention in both directions: **presence means `true`, absence means `false`**. | State | Attribute | `toAttribute` returns | | ------- | -------------------- | --------------------- | | `true` | present, empty value | `''` | | `false` | absent | `null` | Any present value reads as `true`, including the literal `flag="false"`, just as native `disabled="false"` is still disabled. Removing the attribute always yields `false`, never the declared default. Use `toggleAttribute(name, bool)` to set them. Writing `setAttribute(name, String(bool))` always means `true`; wcb warns in the console when it sees a boolean attribute written as `"true"` or `"false"` so the inversion cannot fail silently. Attributes whose `"false"` is meaningful (`aria-*`, `contenteditable`) should be declared as **string** props. See it live: [Boolean props demo ↗](https://demo.webcomponent.io/examples/boolean-props/) ## html Source: https://webcomponent.io/api/html/ The html tagged template function and the vnode shape it produces. A tagged template function that turns markup into a vnode tree, for use as a component's [`template`](https://webcomponent.io/api/web-component/#template). ```js import { html } from 'web-component-base' // or import { html } from 'web-component-base/html.js' ``` ```js get template() { return html`

Hello, ${this.props.name}!

` } ``` It is [htm](https://github.com/developit/htm) bound to a hyperscript factory, so the full htm syntax applies: standard HTML, self-closing tags, `${}` interpolation in text and attribute positions, spread props (`...${obj}`), and optional closing tags (``). ### Return value | Markup | Returns | | ----------------- | ------------------------------------------- | | a single root | one vnode object | | several roots | an array of vnodes | | nothing | `undefined` | A vnode is a plain object: ```js html`

hi

` // { type: 'p', props: { class: 'a' }, children: ['hi'] } ``` | Field | Type | Description | | ---------- | ----------------- | ---------------------------------------------------- | | `type` | `string` | the tag name | | `props` | `object \| null` | attributes and properties as written | | `children` | `any[]` | child vnodes and text; text stays as a raw value | Because the tree is a plain object it is comparable and serializable, which is what lets `render()` diff one render against the next. `` html`` `` returns `undefined`. This is the idiomatic way for a component to render nothing, and it empties the rendered subtree rather than leaving the previous render on screen. See it live: [Templating demo ↗](https://demo.webcomponent.io/examples/templating/) ### How props are applied Each entry in `props` is applied by [`applyProp`](https://webcomponent.io/api/utils/#applypropel-prop-value), in this order: 1. a `style` object is applied rule by rule 2. a name the element owns as a **DOM property** is assigned to that property, so event handlers (`onclick=${fn}`) and non-string values keep their type 3. a boolean value with no matching DOM property is toggled as an HTML boolean attribute 4. anything else is serialized and set as an attribute The same rule applies to freshly created and patched elements, so a prop behaves identically on first render and re-render. A `style` prop accepts an object of camelCase CSS properties: ```js html`
x
` ``` See it live: [Style objects demo ↗](https://demo.webcomponent.io/examples/style-objects/) ### Re-rendering Returning a vnode tree opts into in-place reconciliation: elements of the same tag are reused, only changed props and text are touched, and leftover nodes are trimmed. See [Template vs Render](https://webcomponent.io/template-vs-render/) for what that preserves and the non-keyed matching caveat. See it live: [Render reconciliation demo ↗](https://demo.webcomponent.io/examples/render-reconciliation/) ## Utilities Source: https://webcomponent.io/api/utils/ Case conversion, attribute serialization, element creation and the vnode reconciler. The helpers `WebComponent` uses internally, exported so you can use them directly. Import from the `utils` entry point or each module separately: ```js import { serialize, getKebabCase } from 'web-component-base/utils' // or import { serialize } from 'web-component-base/utils/serialize.js' ``` See it live: [Just the parts demo ↗](https://demo.webcomponent.io/examples/just-parts/) builds a component from these helpers without extending the base class. ### Case conversion #### `getCamelCase(kebab)` Converts a kebab-case attribute name to its camelCase prop key. | Parameter | Type | | | ----------- | -------- | ------------------------ | | `kebab` | `string` | the attribute name | | **returns** | `string` | the prop key | ```js getCamelCase('max-count') // 'maxCount' ``` #### `getKebabCase(str)` Converts a camelCase prop key to its kebab-case attribute name. This is the mapping `observedAttributes` uses. | Parameter | Type | | | ----------- | -------- | ------------------------ | | `str` | `string` | the prop key | | **returns** | `string` | the attribute name | ```js getKebabCase('maxCount') // 'max-count' ``` Consecutive capitals are treated as one word, so `parseHTML` becomes `parse-html`. ### Attribute serialization #### `serialize(value)` Converts a value to its attribute string form. | Parameter | Type | | | ----------- | -------- | -------------------------------------- | | `value` | `any` | the value to serialize | | **returns** | `string` | the attribute value | Numbers, booleans and objects go through `JSON.stringify`; strings and everything else pass through unchanged. #### `deserialize(value, type)` Parses an attribute string back into a value of the given declared type, the inverse of `serialize()`. | Parameter | Type | | | ----------- | -------- | --------------------------------------------------- | | `value` | `string` | the attribute value | | `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` | | **returns** | `any` | the parsed value | `'boolean'` always returns `true`: strict HTML boolean-attribute semantics, where any present value is true. Absence is handled by the caller and never reaches here. `'number'`, `'object'` and `'undefined'` use `JSON.parse` and throw on malformed input; strings pass through. ### Elements #### `createElement(tree)` Builds real DOM from a vnode tree. | Parameter | Type | | | ----------- | -------- | ---------------------------------------------------- | | `tree` | `any` | a vnode, an array of vnodes, or a text value | | **returns** | `Node` | an element, a `DocumentFragment`, or a text node | An array becomes a `DocumentFragment`; a value with no `type` becomes a text node. Props are applied with `applyProp()` and children are created recursively. #### `applyProp(el, prop, value)` Applies a single vnode prop to an element, using the rule described in [html](https://webcomponent.io/api/html/#how-props-are-applied). | Parameter | Type | | | --------- | --------- | ---------------------------------- | | `el` | `Element` | the element to apply the prop to | | `prop` | `string` | the prop name as written in the vnode | | `value` | `any` | the prop value | Shared with the reconciler, so a patched element gets props by exactly the same rule as a freshly created one. ### Reconciler These power the in-place re-render described in [Template vs Render](https://webcomponent.io/template-vs-render/). Matching is **index-based and non-keyed**. #### `patchChildren(parent, oldChildren, newChildren)` Reconciles a parent node's children from one vnode list to another, patching matches in place and trimming leftovers. | Parameter | Type | | | ------------- | ------ | ---------------------------------------------- | | `parent` | `Node` | the parent node to patch into | | `oldChildren` | `any` | the previous vnode children, or previous tree | | `newChildren` | `any` | the new vnode children, or new tree | #### `patchNode(parent, dom, oldVnode, newVnode)` Reconciles a single node position. Reuses `dom` when the vnode type matches, otherwise replaces it. | Parameter | Type | | | ---------- | ----------------- | ---------------------------------------- | | `parent` | `Node` | the parent node being patched | | `dom` | `Node \| null` | the existing node at this index, if any | | `oldVnode` | `any` | the vnode that produced `dom`, if known | | `newVnode` | `any` | the vnode to render | ## cem-plugin Source: https://webcomponent.io/api/cem-plugin/ The wcbStaticProps CEM analyzer plugin. A plugin for [`@custom-elements-manifest/analyzer`](https://custom-elements-manifest.open-wc.org/) that teaches it to read wcb's `static props` object. ```js import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin' // wcbStaticProps is also available as the module's default export import wcbStaticProps from 'web-component-base/cem-plugin' ``` See the [CEM Analyzer Plugin guide](https://webcomponent.io/cem-plugin/) for setup, Storybook and editor integration. ### `wcbStaticProps()` Takes no arguments and returns an analyzer plugin object. | Field | Type | | | -------------- | ----------------------- | ----------------------------------- | | `name` | `string` | the plugin name | | `analyzePhase` | `(ctx: any) => void` | the analyzer hook | ```js // custom-elements-manifest.config.mjs import { wcbStaticProps } from 'web-component-base/cem-plugin' export default { globs: ['src/**/*.js'], outdir: '.', plugins: [wcbStaticProps()], } ``` ### What it does For each class that extends `WebComponent`, it reads the `static props` initializer and, for every key, records in the manifest: - the **member** under its camelCase name - the **attribute** under its kebab-case name, linked to that member - the **type** inferred from the default value: `boolean`, `number`, `object` or `string` - the **default value** as written Without it the analyzer sees `props` as one opaque static field and emits no attributes, so editor completion and Storybook controls have nothing to read. The `static props` initializer must resolve to an object literal in the same source file. ### `distPaths(options?)` A companion plugin that rewrites each module's `path` in the manifest from the scanned source to the built output a package publishes, so a shipped `custom-elements.json` points at files consumers can actually import. ```js // custom-elements-manifest.config.mjs import { wcbStaticProps, distPaths } from 'web-component-base/cem-plugin' export default { globs: ['src/**/*.ts'], outdir: '.', plugins: [wcbStaticProps(), distPaths()], } ``` | Option | Type | Default | | | --------- | ------------------------ | -------- | ---------------------------------------------- | | `rootDir` | `string` | `'src'` | source directory prefix to replace | | `outDir` | `string` | `'dist'` | built-output directory to point at | | `ext` | `Record` | see below | extension remap, merged over the defaults | Zero-config it maps the `rootDir` prefix to `outDir` and rewrites TypeScript extensions to their emitted JS form — `.ts` → `.js`, `.mts` → `.mjs`, `.cts` → `.cjs`. Other extensions pass through, so a plain `.js` source only has its directory swapped. `options.ext` merges over that default map. References that point back at a module — `exports[].declaration`, `superclass`, `mixins[]` — are rewritten along with the paths, so the `module` a consumer follows still resolves to a module in the manifest. A reference carrying a `package` names another package's layout and is left untouched. It runs in the analyzer's `packageLinkPhase` (after `wcbStaticProps`'s `analyzePhase`), so ordering the two in the `plugins` array does not matter. Run `cem analyze` after your build so the files the rewritten paths point at exist. See the [publishing note in the guide](https://webcomponent.io/cem-plugin/#ship-the-manifest-with-a-package-distpaths).