Writing Web Components for wcp
This guide covers the conventions you must follow when writing or contributing components to wcp, or when building your own components on top of its primitives. The rules here ensure consistent behavior across all major frameworks - React, Vue, Angular, Preact, and plain HTML.
For background on the underlying platform, see Custom Element Best Practices.
Attributes & Properties
Mapping Properties to Attributes
Every public property must have a corresponding HTML attribute. When a property name uses camelCase, its attribute must use the equivalent kebab-case name.
| Property | Attribute |
|---|---|
value | value |
defaultValue | default-value |
isOpen | is-open |
One-Way Data Flow: Attribute → Property Only
Data flows in one direction: from attribute to property. Properties must never write back to attributes.
When an attribute changes, parse its value and assign it to the corresponding property. The reverse - updating an attribute when a property changes - must not happen.
Why this matters: Attributes represent serialized, declarative markup. Properties represent live runtime state. These are distinct concepts in the DOM. Setting a property does not update its attribute unless the spec explicitly defines that reflection behavior.
To illustrate: if a component is initialized with value="50" in HTML, and then element.value = 100 is set in JavaScript, calling element.getAttribute('value') will still return "50" - the original serialized attribute value. This is correct and expected behavior.
Rule of thumb:
- A changed attribute → updates the property
- A changed property → does not update the attribute
Parsing Attribute Values
Attributes are always strings. Each type must be parsed explicitly:
Boolean Follows the HTML standard: the attribute is true if present, regardless of its value. Because HTML has no way to express false as a present attribute, the property must default to false, with true implied by the attribute’s presence.
Number Parse with parseInt or parseFloat as appropriate. If the result is NaN, fall back to the property’s default value.
Array Split on a separator that fits your use case.A space separator is recommended.
Object Using objects as attributes is discouraged. If unavoidable, encode the value as JSON.
Null / Missing If the attribute is absent or removed, treat its value as null and fall back to the property’s default value.
Ownership
The host element belongs to the user. Components must never add, modify, or remove attributes on the host element that were set externally. Doing so breaks user expectations and framework integrations.
Methods
Public methods must be defined as readonly arrow functions, not as standard class methods.
class MyComponent extends HTMLElement { // ✅ Correct readonly focusInput = () => { this.input?.focus(); };
// ❌ Avoid focusInput() { this.input?.focus(); }}Why readonly arrow functions? TypeScript cannot distinguish a standard class method from a property that holds a function (i.e., a prop passed in from a framework). Declaring public methods as readonly arrow functions removes that ambiguity: TypeScript treats them as callable instance members, not as assignable props.
Events
Don’t Reuse Native Event Names
Never dispatch a custom event using a name that already exists in the DOM (e.g., input, change, click). If you do, both the native element and your component may fire an event with the same name, producing duplicate or conflicting behavior.
Naming Convention
Use lowercase letters only - no hyphens, no camelCase. This ensures the event name works correctly as an attribute-style listener in all frameworks.
| ✅ Do | ❌ Avoid |
|---|---|
valuechange | value-change |
menuopen | menuOpen |
Intent Events vs. State Events
Components often need to communicate two different things: that the user did something, and that the component’s state changed. These are separate concerns and must be modeled as separate events.
Intent events signal a user-initiated action.
- Fire only when the change originates from direct user interaction (click, keypress, touch, etc.).
- Do not fire when the same change is triggered programmatically (via a prop, attribute, or method call).
- Example:
opened- the user opened the accordion.
State events signal that internal state changed, regardless of cause.
- Fire whenever the state actually changes - whether from user interaction or programmatic updates.
- Example:
open- the accordion’sopenstate changed.
Defining Events
Declare a static _events property on your component class. Each entry is a factory function that constructs the event, which lets TypeScript infer both the event name and its detail type from a single source of truth.
For events with no payload, use a zero-argument factory:
static readonly _events = { _valueChange: () => new CustomEvent("valuechange"),};For events with a typed payload, pass the detail as an argument:
static readonly _events = { _select: (detail: HTMLElement) => new CustomEvent("select", { detail }),};Dispatch by calling the factory inline:
this.dispatchEvent(MyCounter._events._valueChange());this.dispatchEvent(MyCounter._events._select(this));Reactivity
Most frameworks manage UI updates through their own reactivity systems - React state, Vue refs, Preact signals, and so on. Web components must be designed with this in mind.
The problem: Frameworks like React cannot call class methods directly through JSX props. They can only update property values reactively.
The solution: For every public method that changes component state, provide a corresponding reactive property that triggers the same behavior when set.
class MyDialog extends HTMLElement { #opened = false;
get opened() { return this.#opened; }
set opened(value: boolean) { if (value) { this.openDialog(); } else { this.closeDialog(); } }
readonly openDialog = () => { this.#opened = true; // ... open logic };
readonly closeDialog = () => { this.#opened = false; // ... close logic };}This way, a React component can control the dialog by setting opened={openState} as a prop, and the component will respond correctly.
Build & Conventions
Prefer private static methods and fields wherever possible. Static members are shared across all instances rather than allocated per-instance, reducing memory usage and keeping implementation details encapsulated.
Prefix internal properties with
_on any class or object to signal to the minifier that the name should be mangled during a production build. These properties will no longer be accessible by their original name from outside the bundle, so never use the_prefix on anything meant to be part of a public API.CSS class names and CSS variables are also minified during a production build, across HTML, CSS, and JS. The exception is CSS variables prefixed with
--wcp-, which are preserved as-is. To prevent a class name or CSS variable from being minified, prefix it with_. Be especially careful with dynamically constructed class names or variable names, as the minifier will not be able to trace them reliably. It is recommended to use theWCP ESLint Pluginto catch these cases during development.