Writing Web Components for staticview
This guide is for writing staticview components, or your own components built with its build tool.
Some rules are required by the build or by frameworks, and each one says why. The rest are conventions that staticview's own components follow.
Start with staticview create. It scaffolds a component that already follows the required rules.
Component File
Imports
A component file can only import and export types. Each component builds into its own standalone script, and a runtime import breaks it. To reuse another component, nest its element in your shadow root.
Framework Types
The type generator builds the framework types from the tag name, the observed attributes, and the exported types:
import type { SV } from "@staticview/ui";
export type MyCounterTypes = SV.WComponent<typeof MyCounter>;
class MyCounter extends HTMLElement implements SV.IWebComponent { static readonly _componentName = "my-counter"; static readonly observedAttributes = ["value", "step"] as const;}Minified Names
A production build shortens names across the component's HTML, CSS, and JS:
| Name | Shortened | Kept |
|---|---|---|
| JS member | members starting with _ | everything else |
| CSS class | every class | classes starting with _ |
| CSS variable | every variable | --sv-* and --_* variables |
Keep the _ prefix off public API. Write class and variable names as full string literals. The build cannot follow a name built at runtime. The Staticview ESLint Plugin catches these.
Attributes and Properties
Attribute Names
Give each public property that can be written in HTML an attribute with the kebab-case form of its name. Functions and complex objects stay properties only.
| Property | Attribute |
|---|---|
value | value |
defaultValue | default-value |
isOpen | is-open |
Treat this as a starting point. Whether a property gets an attribute, and which one, depends on how it is used.
Parsing Values
Attributes are always strings. Parse each one into the property's type:
| Type | Parsing |
|---|---|
| Boolean | true when the attribute is present, whatever its value. |
| Number | parseInt or parseFloat, falling back to the default on NaN. |
| Array | Split on a separator that fits the data, usually a space. |
| Object | Best avoided. If you need one, use JSON. |
| Missing | Falls back to the property's default. |
A boolean defaults to false, because HTML cannot turn a present attribute off. When the default has to be true, use a string attribute that accepts "true" and "false" instead.
Undefined Values
Every setter has to handle undefined, whatever type it declares. The generated framework types make each property an optional prop, so frameworks like React pass undefined to the setter. TypeScript does not catch it, because the setter's own type never includes undefined.
Fall back to the property's default, like false for a boolean or "" for a string. Some APIs do something unexpected with undefined. toggleAttribute(name, undefined) and classList.toggle(name, undefined) flip the current state instead of turning it off. setAttribute(name, undefined) writes the string "undefined".
class MySwitch extends HTMLElement { set disabled(isDisabled: boolean) { this.toggleAttribute("disabled", Boolean(isDisabled)); }
set name(value: string) { this.setAttribute("name", value ?? ""); }}Reflection
Data flows from attribute to property. Reflect a property back to its attribute only when CSS or the browser reads that attribute. CSS reads state like disabled, readonly, or vertical through selectors such as my-element[vertical]. The browser reads attributes like name and form natively.
class MySlider extends HTMLElement { get vertical(): boolean { return this.hasAttribute("vertical"); } set vertical(isVertical: boolean) { this.toggleAttribute("vertical", Boolean(isVertical)); }}Other properties leave their attribute alone. The attribute keeps what the markup said, and the property holds the current value.
Host Attributes
The host element belongs to the user. Frameworks overwrite the attributes they manage, and users expect their markup to stay as written. Touch the host only to reflect a property, or to add a default the component needs, like tabindex="0", role, or popover, when the user has not set one.
Methods
State Names
Name the state in the past tense and the methods that change it as verbs. The change event is the property name plus change.
| Property | Methods | Event |
|---|---|---|
opened | open, close, toggle | openedchange |
checked | check, uncheck, toggle | checkedchange |
Declaring Methods
Public methods have to be readonly arrow functions. TypeScript cannot tell a class method apart from a property that holds a callback, and the framework types would list the method as a settable prop.
class MyInput extends HTMLElement { readonly #inputEl = document.createElement("input");
// Do readonly focusInput: () => void = () => { this.#inputEl.focus(); };
// Avoid focusInput() { this.#inputEl.focus(); }}Properties for Methods
Every public method that changes state needs a property that does the same thing when set. Frameworks like React can set properties through JSX, but they cannot call methods.
class MyDialog extends HTMLElement { get opened(): boolean { return this.#opened; } set opened(isOpen: boolean) { if (isOpen) { this.open(); } else { this.close(); } } #opened: boolean = false;
readonly open: () => void = () => { this.#opened = true; };
readonly close: () => void = () => { this.#opened = false; };}A React component can now control it with an opened={isOpen} prop.
Events
Declaring Events
Declare events as factories in a static _events object. TypeScript infers each event's name and detail type from it.
class MyCounter extends HTMLElement { static readonly _events = { _clicked: (detail: { value: number }) => new CustomEvent("clicked", { detail, cancelable: true }), _valueChange: () => new CustomEvent("valuechange"), };
readonly increment: () => void = () => { this.dispatchEvent(MyCounter._events._valueChange()); };}Event Names
Event names can only use lowercase letters.
| Do | Avoid | Problem |
|---|---|---|
valuechange | value-change | Hyphens break framework listeners |
openedchange | openedChange | Uppercase breaks framework listeners |
clicked | onclicked | Frameworks add the on themselves |
valuechange | change | Collides with the native event |
Event Types
staticview components keep what the user did apart from what changed.
Intent Events
An intent event reports a user action, and is named after it in the past tense, like clicked. It fires only on user interaction, before anything changes. Its detail holds the state the action would set, and canceling it stops the change.
State Events
A state event reports that the state changed, like openedchange. It fires after every change, whether the user or code caused it. It carries no detail. Read the new state from the element.
Transition Events
A component that animates a state change fires transitionfinish when the animation ends. It fires right away when nothing animates. Read the state from the element to know which way it went.
Native Events
Keep the native events on the host correct, without declaring them as component events. A form component fires the native input and change events, the way its native counterpart does. Form libraries and framework bindings listen for them, and frameworks already type them. Dispatch them as plain Event objects, which the docs generator leaves out.
They report user changes only. input fires on each change. change fires when the user finishes, like on a click, a key press, or the release of a drag. Fire both after the state event. Neither fires for a change from code, a form reset, a restored state, a canceled intent event, or a value that stays the same.
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));this.dispatchEvent(new Event("change", { bubbles: true }));A native event from an inner element, like the input of a text field in the shadow root, reaches the host too. Stop it when its meaning on the host would be wrong.
Styling
Customization Layers
A component can be customized in five layers, from broad to specific:
| Layer | How |
|---|---|
| Browser | System colors like AccentColor and ButtonFace |
| Theme | Shared tokens like --sv-accent, see Theming |
| Component tokens | --sv-<component>-* variables |
| Parts | A part attribute on inner elements |
| Slots | Named slots for content, like an icon |
Token Fallbacks
Each component token falls back to the theme token, then to a system color:
.thumb { background-color: var(--sv-slider-thumb-bg, var(--sv-gray-2, ButtonText));}The component follows the user's theme and still works without one. For system colors that some browsers lack, like AccentColor, add a fallback under @supports not (color: AccentColor).
Internal CSS Variables
Don't declare internal variables on :host. The host is part of the light DOM, and its variables leak into slotted content and nested components. Their shortened names can clash there.
Declare them on .root, the top element of the shadow HTML. Read each public token there once. Use the internal variable everywhere else to keep the CSS small.
.root { --thumb-color: var(--sv-slider-thumb-bg, var(--sv-gray-2, ButtonText));}
.thumb { background-color: var(--thumb-color);}Documentation
The docs, framework types, and manifests are all generated from JSDoc. Document every public property, method, event, slot, part, and CSS variable, or it won't show up in them. See Documenting Web Components for the tags.