Skip to content

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.