docs: tighten the "why" page (grammar and figures)

This commit is contained in:
ayo 2026-07-22 01:18:01 +02:00 committed by GitHub
parent b4ce4c8cf7
commit cef76dfd66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 97 additions and 95 deletions

View file

@ -44,7 +44,7 @@ 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`
- the **type** inferred from the default value: `boolean`, `number`, `object`
or `string`
- the **default value** as written

View file

@ -48,7 +48,7 @@ html`<p class="a">hi</p>`
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
`` 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.
@ -60,7 +60,7 @@ Each entry in `props` is applied by [`applyProp`](/api/utils/#applypropel-prop-v
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
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

View file

@ -63,7 +63,7 @@ everything else pass through unchanged.
### `deserialize(value, type)`
Parses an attribute string back into a value of the given declared type the
Parses an attribute string back into a value of the given declared type, the
inverse of `serialize()`.
| Parameter | Type | |
@ -72,7 +72,7 @@ inverse of `serialize()`.
| `type` | `string` | `'boolean'`, `'number'`, `'object'`, `'undefined'` or `'string'` |
| **returns** | `any` | the parsed value |
`'boolean'` always returns `true` strict HTML boolean-attribute semantics,
`'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.

View file

@ -1,7 +1,7 @@
---
title: WebComponent
slug: api/web-component
description: The WebComponent base class — static configuration, instance members, lifecycle hooks and attribute converters.
description: 'The WebComponent base class: static configuration, instance members, lifecycle hooks and attribute converters.'
---
The base class every component extends. Import from the package root or its own
@ -56,8 +56,8 @@ 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 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
@ -80,7 +80,7 @@ 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
Adoption happens **once per instance**, when the element is constructed, not
per render.
Requires [`shadowRootInit`](#static-shadowrootinit). Without a shadow root
@ -92,7 +92,7 @@ See it live: [Constructable styles demo ↗](https://demo.webcomponent.io/exampl
### `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
object. Its presence is what opts the component into shadow DOM. The shadow
root is attached during construction and becomes the render target.
```js
@ -143,9 +143,9 @@ Assigning the value it already holds does nothing.
Read-only getter returning what the component renders. Two kinds are supported:
- an [`html`](/api/html/) tagged template a vnode tree, reconciled in place
- an [`html`](/api/html/) tagged template: a vnode tree, reconciled in place
on re-render
- a **string** assigned to the render target's `innerHTML`
- 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
@ -165,7 +165,7 @@ 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
re-render **patches the existing DOM in place**. See
[Template vs Render](/template-vs-render/) for what that preserves and the
non-keyed matching caveat.
@ -202,7 +202,7 @@ 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
restore (`Date`, `Map`, `Set`, `URL`, class instances) need overridden
converters to live on `static props`; see
[Custom attribute conversion](/prop-access/#custom-attribute-conversion) for
worked examples, including the non-serializable cases.
@ -229,7 +229,7 @@ toAttribute(name, value) {
### `fromAttribute(name, value)`
Converts an attribute value into the prop value it represents the inverse of
Converts an attribute value into the prop value it represents, the inverse of
`toAttribute()`.
| Parameter | Type | Description |
@ -256,7 +256,7 @@ Boolean props follow the HTML convention in both directions: **presence means
| `true` | present, empty value | `''` |
| `false` | absent | `null` |
Any present value reads as `true` including the literal `flag="false"`, just
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.
@ -265,7 +265,7 @@ Use `toggleAttribute(name, bool)` to set them. Writing
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
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/)

View file

@ -6,7 +6,7 @@ slug: cem-plugin
import { Aside } from '@astrojs/starlight/components'
A CEM (`custom-elements.json`) is a standard description of the elements a
package defines — their tags, attributes, properties, events and slots — so
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
@ -20,7 +20,7 @@ We provide a CEM Analyzer Plugin `web-component-base/cem-plugin` which allows us
set up: a starter component, this plugin configured in
`custom-elements-manifest.config.mjs`, an `analyze` script, and the
`customElements` field set in `package.json`. The rest of this guide is the
manual path use it to add the manifest to an existing project, or to
manual path: use it to add the manifest to an existing project, or to
understand what the scaffold configured.
</Aside>
@ -53,10 +53,10 @@ npx cem analyze
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
- **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.
- **No `package.json` in the directory**: it hangs outright.
With globs scoped to your source it should finish in well under a second.
@ -92,10 +92,10 @@ customElements.define('cozy-button', CozyButton)
Two details worth knowing:
- **Types come from the default literal** `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `string`.
- **Types come from the default literal**: `true`/`false` → `boolean`, numeric → `number`, object/array → `object`, everything else → `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.
The defaults may be written inline or hoisted into a module-level `const` — the latter is required by the [typed props](/prop-access/#typed-props-in-typescript) pattern, and the plugin resolves it either way.
The defaults may be written inline or hoisted into a module-level `const`. The latter is required by the [typed props](/prop-access/#typed-props-in-typescript) pattern, and the plugin resolves it either way.
## Storybook
@ -113,7 +113,7 @@ setCustomElementsManifest(manifest)
export default { tags: ['autodocs'] }
```
Bind a story to the tag name and Storybook infers the rest a text field for `variant`, a toggle for `disabled`, a number input for `maxCount`:
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
@ -133,19 +133,19 @@ export const Default = { args: { variant: 'primary', disabled: false } }
<Aside type="tip">
Regenerate the manifest before starting Storybook (`cem analyze && storybook
dev`) `custom-elements.json` is a build artifact, so it is usually
dev`). `custom-elements.json` is a build artifact, so it is usually
gitignored and rebuilt on demand.
</Aside>
This repo runs exactly this setup against the demo components in `storybook/` — see it there for a working reference.
This repo runs exactly this setup against the demo components in `storybook/`. See it there for a working reference.
## 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.
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.
<Aside type="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.
just because the file exists. You need one of the two routes below.
</Aside>
First, point tooling at the manifest from your `package.json`. This is the field every option here uses to discover it:
@ -156,7 +156,7 @@ First, point tooling at the manifest from your `package.json`. This is the field
}
```
### Route 1 native VS Code, no extension
### 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:
@ -187,25 +187,25 @@ export default {
`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
`outdir`, `generateCustomData({ outdir: '..' })`, so the file lands where
the setting expects it.
</Aside>
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.
**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
### 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](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`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.
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](https://marketplace.visualstudio.com/items?itemName=pwrs.cem-language-server-vscode) (`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`](https://wc-toolkit.com/integrations/web-components-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.
- [`wc-toolkit/wc-language-server`](https://wc-toolkit.com/integrations/web-components-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.
<Aside type="note">
This corner of the ecosystem moves quickly and every option is pre-1.0. Route
1 is the conservative choice it is plain VS Code configuration with no
extension to go stale at the cost of not covering tagged templates.
1 is the conservative choice (it is plain VS Code configuration with no
extension to go stale) at the cost of not covering tagged templates.
</Aside>

View file

@ -17,13 +17,13 @@ Numbers below are **measured** from the same minimal counter component (one reac
| `@elenajs/core` | 1.0.0 | 9.1 kB | 3.7 kB | 3.3 kB |
| `lit` | 3.3.3 | 15.3 kB | 5.9 kB | 5.3 kB |
| `@microsoft/fast-element` | 3.0.1 | 44.9 kB | 13.7 kB | 12.2 kB |
| vanilla `HTMLElement` | — | — | — | ~0.2 kB |
| vanilla `HTMLElement` | - | - | - | ~0.2 kB |
For scale: even after all of the v5.2v6.1 work, the WCB counter is **~21% smaller than Elena, ~51% smaller than Lit, and ~79% smaller than FAST**.
## Feature comparison
What each library gives you beyond extending directly from `HTMLElement` the boilerplate you no longer write by hand:
What each library gives you beyond extending directly from `HTMLElement`, the boilerplate you no longer write by hand:
| Capability | WCB 6.1 | Lit 3.3 | Elena 1.0 | FAST 3.0 |
| -------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | --------------------------------------- | ------------------------------------------------ |
@ -42,11 +42,11 @@ What each library gives you beyond extending directly from `HTMLElement` — the
| Backing / ecosystem | solo maintainer, small surface | Google, huge ecosystem | new (2026), design-system focus | Microsoft, powers Fluent UI |
:::note[Why 11ty WebC isn't here]
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem and solves it well.
WebC is a compile-time tool: it resolves components during an Eleventy build and ships plain HTML with no client runtime. Every row above is about what a library does _in the browser at runtime_, so a side-by-side comparison would be measuring two different things. If your components are static at build time, WebC solves a different problem, and solves it well.
:::
For what these numbers and capabilities add up to — and when they don't — see [Why would anyone use WCB?](/why/).
For what these numbers and capabilities add up to (and when they don't) see [Why would anyone use WCB?](/why/).
---
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip 9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself — the benchmark is trivially reproducible with the versions pinned above._
_WCB re-measured 2026-07-20 at v6.1.0; the other libraries measured 2026-07-19, with esbuild, Node zlib (gzip 9, brotli q11), at the pinned versions above. Methodology: identical counter component per library, bundled per library, compressed. Re-run them yourself. The benchmark is trivially reproducible with the versions pinned above._

View file

@ -6,7 +6,7 @@ slug: examples
## Live demo gallery
Every example below runs as a standalone page at
[demo.webcomponent.io ↗](https://demo.webcomponent.io/) a live gallery with
[demo.webcomponent.io ↗](https://demo.webcomponent.io/), a live gallery with
the source alongside each demo.
| Demo | Shows |

View file

@ -20,9 +20,9 @@ npm create wcb@latest
It sets up a publishable custom element, ready to develop:
- **A starter component** in TypeScript using the [`static props`](/prop-access/) convention — the class, tag, and file names are derived from your project name (`my-button` → `MyButton` / `<my-button>`), 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
- **A starter component** in TypeScript using the [`static props`](/prop-access/) convention. The class, tag, and file names are derived from your project name (`my-button` → `MyButton` / `<my-button>`), 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:
@ -32,7 +32,7 @@ npm install
npm run dev
```
Vite prints a local URL — open it to see `<my-button>` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save.
Vite prints a local URL. Open it to see `<my-button>` rendered on the demo page. Edit `src/MyButton.ts` and the page updates as you save.
See the [CEM Analyzer Plugin guide](/cem-plugin/) for more details on the manifest and how to use it with Storybook and code editors.
@ -57,7 +57,7 @@ import { WebComponent } from 'web-component-base'
Continue with [Usage](/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](/examples/#codepen-examples) for working setups.
You can also import wcb straight from a CDN in vanilla JS or HTML files. See the [CodePen examples](/examples/#codepen-examples) for working setups.
:::
## Getting help

View file

@ -77,9 +77,9 @@ class ClickableText extends WebComponent {
- 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
- `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.
@ -117,7 +117,7 @@ If you previously read `changes.property` for the attribute name, switch to `cha
## Upgrade ordering & the buffering guarantee
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), 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.
Per the Custom Elements spec, when an element is upgraded with attributes already present in the markup (e.g. `<my-el my-name="Zoe">`), 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:
@ -126,8 +126,8 @@ Per the Custom Elements spec, when an element is upgraded with attributes alread
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
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.
**`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.

View file

@ -65,7 +65,7 @@ class FlagBox extends WebComponent {
<flag-box flag=""></flag-box>
```
Reflection works the same way in reverse `true` sets the bare attribute,
Reflection works the same way in reverse. `true` sets the bare attribute,
`false` removes it entirely:
```js
@ -88,7 +88,7 @@ el.toggleAttribute('flag', true) // prop syncs, component re-renders
<Aside type="caution" title="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.
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
@ -96,7 +96,7 @@ el.toggleAttribute('flag', true) // prop syncs, component re-renders
</Aside>
Enumerated attributes like `contenteditable` and `aria-*` attributes are the
exception — they are genuine strings where `"false"` is meaningful, so declare
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 you
can narrow them to the values you accept:
@ -109,7 +109,7 @@ const props = { ariaChecked: 'false' as 'true' | '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
class on a `true` default, and for such a prop, removing the attribute lands
on `false`, not back on the declared default.
</Aside>
@ -118,8 +118,8 @@ const props = { ariaChecked: 'false' as 'true' | 'false' }
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
serialization (a `Date`, a delimited list, an enumerated attribute where
`"false"` is meaningful) override `toAttribute` and `fromAttribute`, and
delegate everything else to `super`:
```js
@ -144,7 +144,7 @@ class EventCard extends WebComponent {
```
Both take the **camelCase prop key**, matching your `static props` declaration
and `onChanges`'s `property` not the kebab-case attribute name.
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:
@ -158,7 +158,7 @@ toAttribute(name, value) {
<Aside type="note" title="Reflection does not round-trip">
When a prop write reflects to its attribute, wcb does **not** parse the
attribute back through `fromAttribute` — the prop you assigned is already the
attribute back through `fromAttribute`. The prop you assigned is already the
source of truth. So `toAttribute` may be lossy without corrupting the prop: in
the example above `props.when` keeps its full timestamp even though the
attribute carries only the date. `render()` and `onChanges` still fire as
@ -169,7 +169,7 @@ toAttribute(name, value) {
<Aside type="caution" title="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
actual `Date` default (`new Date(0)`), not `''`. Otherwise the prop is typed
`string` and your parsed `Date` is refused.
</Aside>
@ -180,14 +180,14 @@ 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 `"{}"` before it ever reaches the attribute.
Such types are still first-class props — declare a real default of that type
Such types are still first-class props. Declare a real default of that type
(see the caution above) and override the converters. You can hand-write a
textual form per prop, as `EventCard` above does for its `Date`; when several
props need this, delegate to a serializer built for the job instead:
[devalue](https://github.com/sveltejs/devalue) round-trips `Map`, `Set`,
`Date`, `RegExp`, `BigInt`, `undefined`, and even cyclic references. One
generic pair of overrides then covers every structured prop, present and
future — here is `EventCard` rewritten with it, gaining a `Set` of tags along
future. Here is `EventCard` rewritten with it, gaining a `Set` of tags along
the way:
```js
@ -213,12 +213,12 @@ class EventCard extends WebComponent {
```
The two guards are asymmetric on purpose: `toAttribute` sees the live value,
but `fromAttribute` only ever sees a string so it consults the declared
but `fromAttribute` only ever sees a string, so it consults the declared
default 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 hand-friendly as a bespoke `when="2026-07-20"`,
but it is still plain text — write it in markup with single quotes, since the
but it is still plain text. Write it in markup with single quotes, since the
payload contains double quotes:
```html
@ -229,22 +229,22 @@ payload contains double quotes:
></event-card>
```
devalue is your component's dependency, not wcb's — the base class stays
devalue is your component's dependency, not wcb's. The base class stays
zero-dependency.
<Aside type="caution" title="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
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')`.
</Aside>
Converters cover any value with a sensible **textual form** bespoke like an
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
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
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.
@ -253,7 +253,7 @@ reason.
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 — the type above, the defaults within:
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 (the type above, the defaults within):
```ts
type CozyButtonProps = {
@ -283,11 +283,11 @@ Unions stay narrow with nothing to cast, and the annotation cuts both ways:
the defaults themselves are checked against the type, so a missing key or a
default outside the union is a compile error too.
This is types-only — the runtime is unchanged, and `static strictProps` still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
This is types-only. The runtime is unchanged, and `static strictProps` still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
<Aside type="tip">
You can also infer the type from the defaults instead of writing it out:
declare them in a `const` and pass its `typeof`
declare them in a `const` and pass its `typeof`:
`class CozyButton extends WebComponent<typeof props> { static props = props }`.
Inferred values widen (`variant: 'primary'` types as plain `string`), so
narrow with a cast where it matters:
@ -299,18 +299,18 @@ This is types-only — the runtime is unchanged, and `static strictProps` still
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.
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:
machinery (no attribute, no observation, no automatic render):
```js
class DataTable extends WebComponent {
static props = { compact: false } // public API: <data-table compact>
rows = [] // internal state never becomes an attribute
rows = [] // internal state, never becomes an attribute
#controller = new AbortController() // non-serializable values are fine here
async onInit() {
@ -330,8 +330,8 @@ Because nothing watches a plain property, call
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
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.

View file

@ -91,10 +91,10 @@ 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:
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
// tokens.js: shared across every component
export const tokens = `
:host {
--cozy-radius: 6px;
@ -131,4 +131,4 @@ class CozyBadge extends WebComponent {
}
```
A single string keeps working exactly as before — the array form is additive.
A single string keeps working exactly as before. The array form is additive.

View file

@ -11,7 +11,7 @@ This mental model attempts to reduce the cognitive complexity of authoring compo
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.
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
@ -33,7 +33,7 @@ class CounterBoard extends WebComponent {
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
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

View file

@ -3,21 +3,23 @@ title: 'Why would anyone use WCB?'
slug: why
---
The `WebComponent` base class gives a full component development experience at the lightest-weight possible: the minimum code to boost productivity.
The `WebComponent` base class gives a full component development experience at the lightest weight possible: the minimum code to boost productivity.
The following are the main reasons WCB exist.
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. Every alternative with comparable ergonomics costs 1.4×5× more. 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 `<script type="module">` works on current browsers. The whole mental model is `static props` + `template` + four hooks, and the shipped source is readable in one sitting — the entire library is smaller than most libraries' _documentation_ on their update scheduling.
3. **Attribute-first reactivity is HTML-native.** Because props serialize to attributes, initial state can be rendered by _any_ server in plain HTML — no JS-framework SSR integration needed — and components stay inspectable/debuggable in devtools as ordinary attributes.
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 `<script type="module">` works on current browsers. The whole mental model is `static props` + `template` + four hooks, and the shipped source is readable in one sitting. The runtime is 2.6 kB brotli-compressed ([measured](/comparison/)).
3. **Attribute-first reactivity is HTML-native.** Because props serialize to attributes, initial state can be rendered by _any_ server in plain HTML (no JS-framework SSR integration needed) and components stay inspectable/debuggable in devtools as ordinary attributes.
4. **Light DOM by default.** Global stylesheets, forms, and third-party CSS just work; and the shadow DOM is one static field away when you want encapsulation.
5. **The size gate is a governed value.** Every byte added must justify itself in the [size change log](https://github.com/ayo-run/wcb/blob/main/size-change-log.md), enforced by `size-limit` budgets in CI. Smart diffing is the largest single addition in the project's history and it cost 0.42 kB.
**When WCB is the wrong choice** — pick honestly:
## When WCB is the wrong choice
Pick honestly:
- **Reorderable lists of stateful items**: patching is positional, not keyed; preserved focus/animation follows the position, not the item. Lit's `repeat` or FAST handle this correctly.
- **High-frequency updates on large trees**: WCB re-renders synchronously per prop write and diffs the whole vnode tree; Lit/FAST update only the affected bindings on a batched schedule.
- **SSR with client hydration**: Lit has a real hydration story; Elena is built around progressive enhancement. WCB renders client-side after connect.
- **Big-team, long-horizon design systems**: Google (Lit) and Microsoft (FAST) have big backing and ecosystems; while Elena is purpose-built for design systems.
- **SSR with client hydration**: WCB renders on the client after `connectedCallback`, so there's no hydration step. If you need server-rendered markup that hydrates in place, use Lit (`@lit-labs/ssr`).
- **Big-team, long-horizon design systems**: WCB is a solo-maintained library with a deliberately small surface. For large-org backing and ecosystem, choose Lit (Google) or FAST (Microsoft, powers Fluent UI).
For the measured numbers and the capability-by-capability breakdown behind these claims, see [WCB & similar libraries](/comparison/).