Skip to content

Components

WebJs components are standard HTML custom elements built on a thin base class called WebComponent. If you are coming from React, think of WebComponent as a class component whose render method returns a tagged template instead of JSX. The browser owns the component lifecycle. There is no virtual DOM, no reconciler, and no framework-specific component model to learn.

The WebComponent Base Class

Every interactive component extends WebComponent, declares its property map by passing a shape into the base-class factory (extends WebComponent({ ... }), and optionally static styles for shadow-DOM components), implements render(), and registers itself by passing a hyphenated tag name to ClassName.register('tag-name'). The tag name is an argument to .register(), not a static field.

import { WebComponent, html, css, signal } from '@webjsdev/core';

class MyCounter extends WebComponent {

  // Instance signal carries component-local state. SignalWatcher
  // (built into WebComponent) auto-tracks .get() reads and re-renders.
  count = signal(0);

  static styles = css`
    :host { display: inline-flex; gap: 8px; align-items: center; }
    button { font: inherit; padding: 4px 12px; cursor: pointer; }
    output { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: center; }
  `;

  render() {
    return html`
      <button @click=${() => this.count.set(this.count.get() - 1)}>-</button>
      <output>${this.count.get()}</output>
      <button @click=${() => this.count.set(this.count.get() + 1)}>+</button>
    `;
  }
}

MyCounter.register('my-counter');

That is a complete, working component. Import it from a page or layout and use it like any HTML element:

import '#components/my-counter.ts';

export default function Home() {
  return html`<my-counter></my-counter>`;
}

Tag Names

The HTML spec requires that custom element names contain a hyphen. This is how the browser distinguishes <my-counter> from built-in elements like <div>. Register the component with Class.register('tag') at the bottom of the file:

class UserCard extends WebComponent {
  // ...
}
UserCard.register('user-card');

If you forget the hyphen, the browser throws at registration time with a clear error message.

Properties

Reactive properties that ride HTML attributes are declared in one place: the base-class factory. You pass the property shape directly into WebComponent({ ... }), the types flow to TypeScript automatically (no declare lines), and the runtime installs a reactive accessor for each key. A hand-written static properties = { ... } field in the class body is no longer supported and throws at construction.

The factory shape

Map each property name to its type constructor. Default values are set in the constructor after super() (never a class-field initializer, which runs after super() and clobbers the reactive accessor):

import { WebComponent, html } from '@webjsdev/core';

class UserCard extends WebComponent({
  name:     String,
  age:      Number,
  active:   Boolean,
  config:   Object,
  tags:     Array,
}) {
  constructor() {
    super();
    this.name = 'Anonymous';
    this.age = 0;
    this.active = false;
    this.config = {};
    this.tags = [];
  }

  render() {
    return html`
      <p>${this.name} (age ${this.age})</p>
      <p>Active: ${this.active ? 'yes' : 'no'}</p>
      <p>Tags: ${this.tags.join(', ')}</p>
    `;
  }
}
UserCard.register('user-card');

Property options and narrowed types with prop()

When a property needs options (reflection, a renamed attribute, internal-only state) or a narrowed TypeScript type, wrap the type in the prop() helper. A bare constructor (name: String) is shorthand for name: prop(String).

import { WebComponent, html, prop } from '@webjsdev/core';

interface Student { name: string; gpa: number; }

class UserCard extends WebComponent({
  count:   prop(Number, { reflect: true }),       // reflect to the attribute
  label:   prop(String, { attribute: 'aria-label' }), // renamed attribute
  open:    prop({ state: true }),                  // internal, no attribute
  student: prop<Student>(Object),                  // narrowed object type
  tags:    prop<string[]>(Array),                  // array-typed: pass Array, not Object
  size:    prop<'sm' | 'lg'>(String),              // narrowed enum type
}) {
  constructor() {
    super();
    this.size = 'sm';                              // default in the constructor
  }
  render() {
    return html`<p>${this.student.name} (${this.size})</p>`;
  }
}
UserCard.register('user-card');

Set a default by assigning in the constructor() after super() (never a class-field initializer, which clobbers the reactive accessor). An applied attribute overrides it.

Declare an array-typed property with the Array constructor, not Object (tags: prop<string[]>(Array)). The default converter treats both the same (each JSON-encodes the value), so Object does not break anything, but Array states the property's shape correctly. The array-prop-uses-array-type rule in webjs check flags an array-typed generic declared with Object.

Attribute-to-Property Coercion

When an attribute changes on the DOM element, WebJs coerces the string value to the declared type:

  • String: passed through as-is.
  • Number: converted via Number(value). Null attributes become null.
  • Boolean: the attribute is true if present and not "false". Removing the attribute sets false.
  • Object / Array: parsed via JSON.parse(). If parsing fails, the raw string is used.

Property names are automatically converted between camelCase (JavaScript) and kebab-case (HTML). A property named userName observes the attribute user-name.

If you are coming from React: properties in WebJs serve a similar role to props, but they are backed by real DOM attributes. You can inspect them in DevTools, set them from plain HTML, and they survive page serialization during SSR.

State

Signals are the default state primitive. Import signal from @webjsdev/core and read with signal.get() inside render(). The component's built-in SignalWatcher tracks the read and re-renders whenever the signal changes. Instance signals (class-field initializers) carry component-local state; module-scope signals share state across components.

import { WebComponent, html, signal } from '@webjsdev/core';

class TodoList extends WebComponent {
  items  = signal<{ id: number; text: string; done: boolean }[]>([]);
  filter = signal<'all' | 'active'>('all');

  addItem(text) {
    this.items.set([...this.items.get(), { id: Date.now(), text, done: false }]);
  }

  toggleItem(id) {
    this.items.set(this.items.get().map(it =>
      it.id === id ? { ...it, done: !it.done } : it
    ));
  }

  render() {
    const visible = this.filter.get() === 'all'
      ? this.items.get()
      : this.items.get().filter(it => !it.done);

    return html`
      <ul>
        ${visible.map(it => html`
          <li @click=${() => this.toggleItem(it.id)}
              style=${it.done ? 'text-decoration: line-through' : ''}>
            ${it.text}
          </li>
        `)}
      </ul>
    `;
  }
}
TodoList.register('todo-list');

How signal updates render

  • Dynamic tracking: every render re-records its dependency set. A signal read inside render() subscribes the component to that signal; signals that fall out of the current control flow stop driving re-renders.
  • Batched re-render: calling signal.set (or assigning a reactive property, or calling requestUpdate) multiple times in the same synchronous block only triggers one re-render. Updates are batched via queueMicrotask, so the DOM update happens after the current call stack finishes but before the next frame paints.
// These two writes result in a single re-render, not two:
this.count.set(1);
this.label = 'hello';
// render() is called once with the new count and label.

Fine-grained binding with watch()

Reading signal.get() inside render() subscribes the WHOLE component to that signal: any change re-runs render(). When a single template hole depends on a single signal value and the rest of the template doesn't, the watch(signal) directive from @webjsdev/core/directives is a cheaper alternative: the directive sets up its own per-hole subscription, and only the bound text node (or attribute value) updates when the signal fires. The host's render() does not re-run, which also means shouldUpdate / willUpdate / updated are bypassed for that change.

import { html, signal } from '@webjsdev/core';
import { watch } from '@webjsdev/core/directives';

const count = signal(0);

class Counter extends WebComponent {
  render() {
    // The host subscribes to nothing; only this hole updates.
    return html`
      <button @click=${() => count.set(count.get() + 1)}>
        ${watch(count)}
      </button>
    `;
  }
}
Counter.register('my-counter');

SSR inlines the current value once; subscription is a client-only concern. Pick watch() when the binding is a scalar (text node, attribute value) tied to one signal and the surrounding template is expensive or static. Pick signal.get() when the render branches on the value, derives several things from it, or reads multiple signals together.

Styles

Use the css tagged template to declare scoped styles. They are automatically adopted into the component's shadow root.

import { WebComponent, html, css } from '@webjsdev/core';

class StyledCard extends WebComponent {
  static styles = css`
    :host {
      display: block;
      padding: var(--sp-4);
      border: 1px solid var(--border);
      border-radius: var(--rad-lg);
      background: var(--bg-elev);
    }
    :host(:hover) {
      border-color: var(--border-strong);
      box-shadow: var(--shadow);
    }
    h3 { margin: 0 0 8px; }
    p  { margin: 0; color: var(--fg-muted); }
  `;

  render() {
    return html`
      <h3><slot name="title">Untitled</slot></h3>
      <p><slot></slot></p>
    `;
  }
}
StyledCard.register('styled-card');

How Styles Are Applied

  • adoptedStyleSheets: when the browser supports it (all modern browsers), styles are applied via adoptedStyleSheets on the shadow root. This is the most efficient path: the browser parses the CSS once and shares the CSSStyleSheet object across all instances of the same component.
  • Fallback: on older browsers, a <style> element is injected into the shadow root instead.

Design Tokens via CSS Custom Properties

CSS custom properties (variables) inherit across shadow DOM boundaries. This is the primary mechanism for theming in WebJs. Define tokens on :root or a parent element, and every component in the tree can read them:

/* In your root layout or global stylesheet */
:root {
  --accent: oklch(0.58 0.15 55);
  --bg-elev: white;
  --border: oklch(0.88 0.01 75);
  --rad-lg: 12px;
  --sp-4: 16px;
}

/* Inside a component's static styles, these "just work" */
static styles = css`
  :host {
    background: var(--bg-elev);
    border: 1px solid var(--border);
    border-radius: var(--rad-lg);
    padding: var(--sp-4);
  }
  .accent { color: var(--accent); }
`;
This is fundamentally different from React CSS-in-JS solutions that require runtime injection or build tooling. WebJs uses the platform: shadow DOM gives you scoping, CSS custom properties give you theming, and there is nothing to configure.

Light DOM (default)

Light DOM is the default because global CSS and Tailwind utility classes apply directly: no :host, no ::part, no CSS-variable plumbing. The browser renders a plain custom element with normal children. This is the mode the blog example uses everywhere except when shadow DOM buys something specific.

Light-DOM hosts default to display: block. A custom element is display: inline by default (so a container component would collapse), and a light-DOM component has no :host to fix it with. So the framework marks every light host data-wj-host and injects one low-priority-layer rule (@layer webjs-host { :where([data-wj-host]) { display: block } }), overridable by any author style including Tailwind utilities (class="flex" wins). Shadow-DOM hosts are NOT marked: set their display via :host in static styles, the way Lit does (fully respected). See the styling page.

// static shadow = false is the default, no need to declare it.
class AppCard extends WebComponent({ heading: String }) {
  constructor() {
    super();
    this.heading = '';
  }

  render() {
    return html`
      <div class="rounded-lg border border-border bg-bg-elev p-6">
        <h3 class="font-serif text-lg mb-2">${this.heading}</h3>
        <p class="text-fg-muted">Tailwind utility classes apply directly.</p>
      </div>
    `;
  }
}
AppCard.register('app-card');

Class-prefix rule for custom CSS

Tailwind utilities are unique by construction, so most light-DOM components need zero custom CSS. If you do author a <style> block or import a stylesheet, every class selector MUST be prefixed with the component's tag name. Otherwise two components with a .card or .header class will style each other.

// Pattern A: BEM-ish class names prefixed with tag
class MyCard extends WebComponent {
  render() {
    return html`
      <style>
        .my-card__body  { padding: 16px; }
        .my-card__title { font-weight: 600; }
      </style>
      <div class="my-card__body"><h3 class="my-card__title">${t}</h3></div>
    `;
  }
}

// Pattern B: descendant selector rooted at the tag
class MyCard extends WebComponent {
  render() {
    return html`
      <style>
        my-card .body  { padding: 16px; }
        my-card .title { font-weight: 600; }
      </style>
      <div class="body"><h3 class="title">${t}</h3></div>
    `;
  }
}

Shadow DOM (opt-in)

Set static shadow = true when you want one of these:

  • Scoped styles via static styles = css`...` (adopted via adoptedStyleSheets) without prefix discipline.
  • ::slotted() CSS selectors from inside the shadow tree, since the native browser projection is what makes them work.
  • Third-party embed isolation: your component looks right in any host page, regardless of their CSS.

Slots themselves are NOT a reason to opt into shadow DOM. <slot>, <slot name="x">, fallback content, assignedNodes(), assignedElements(), assignedSlot, slotchange, named-slot routing, and first-wins resolution all work identically in light DOM (the framework's default). See the Slots section below for the full surface.

class Card extends WebComponent {
  static shadow = true;                 // opt in
  static styles = css`
    :host { display: block; padding: 16px; border: 1px solid var(--border); border-radius: 8px; }
    h3 { margin: 0 0 8px; }
    p  { color: var(--fg-muted); margin: 0; }
  `;
  render() {
    return html`
      <h3><slot name="title"></slot></h3>
      <p><slot></slot></p>
    `;
  }
}
Card.register('my-card');

static styles on a light-DOM component is silently ignored. There's no shadow root to adopt them into. If you see your styles failing, check whether you forgot static shadow = true.

Mode summary

Both modes are fully SSR'd. Shadow DOM renders via Declarative Shadow DOM (<template shadowrootmode="open">). Light DOM renders content directly as HTML with a <!--webjs-hydrate--> marker. Both hydrate on the client without flash.

Component typeModeWhy
Global / Tailwind utility classes, simple compositionLight DOM (default)Utilities apply directly. No host plumbing.
static styles = css`` scoped stylesShadow DOMadoptedStyleSheets needs a shadow root.
<slot> content projectionEitherFull shadow-DOM spec parity in light DOM. Same <slot> / <slot name="x"> / fallback / assignedNodes / slotchange in both modes.
Third-party embed needing isolationShadow DOMCSS can't leak in or out.

SSR and the first paint

Every web component on a page runs through the SSR pipeline. For each rendered tag, the server:

  1. Calls new Cls(): the constructor runs.
  2. Applies the element's attributes to the instance (via the factory's property converters).
  3. Calls instance.render() and awaits the resulting template.
  4. Inlines the rendered HTML as the element's children (light DOM) or wraps it in <template shadowrootmode="open"> (shadow DOM).

The server runs the pre-render value-deriving hooks before render(): willUpdate and controllers' hostUpdate, then it reflects reflect: true properties. It does NOT call connectedCallback, firstUpdated, updated, or any browser-only hook. Those run only after the script loads in the browser. This is intentional. The server runs many components for many concurrent requests, and the browser-only hooks frequently touch window, document, localStorage, observers, and timers that don't exist server-side. (A Task is the exception among controllers: its hostUpdate does not auto-run at SSR, so it ships the INITIAL state and fetches only on hydration.)

Rule: SSR-meaningful state goes in the constructor

Whatever state should appear in the first paint MUST be set in the constructor (after super()) or be derivable from the factory's properties + the tag's attributes. The SSR pipeline reads exactly these values.

// ❌ first paint is empty (initial state set in browser-only hook)
class Cart extends WebComponent({ items: prop<Item[]>(Array) }) {
  connectedCallback() {                       // ← server never runs this
    super.connectedCallback();
    this.items = readFromLocalStorage();
    this.requestUpdate();
  }

  render() { return html`<ul>${this.items.map(/* … */)}</ul>`; }
}
// ✅ SSR-safe: instance signal carries the default,
//    browser hook refines after hydration
class Cart extends WebComponent {
  items = signal<Item[]>([]);                  // ← SSR uses this

  connectedCallback() {
    super.connectedCallback();
    const stored = readFromLocalStorage();
    if (stored) this.items.set(stored);         // browser-only refinement
  }

  render() { return html`<ul>${this.items.get().map(/* … */)}</ul>`; }
}

Where each kind of data belongs

Data sourceWhere to read it
Database, session, cookies, request headersPage function (server). Pass to the component as an attribute or property.
Server data a leaf component needs in the first paintAn async render() in the component (const u = await getUser(this.uid)). Co-located, no prop-drilling. See below.
Initial state / defaults known at coding timeInstance signal in a class-field initializer, or the component's constructor() after super().
Browser-only: localStorage, viewport, matchMedia, navigator.*Component's connectedCallback(), then write the signal to refine.
Flash-sensitive (theme, RTL direction)Synchronous inline <script> in the root layout's <head> that writes attributes to document.documentElement before custom elements upgrade.

This is the design rule that makes progressive enhancement work in webjs: the component's HTML lands in the response, with the right content, before any script runs.

Compound components: reading the parent with closest() at SSR

A compound component (a tabs trigger, a toggle-group item) derives its active or pressed state by walking up to the parent and reading the parent's value. WebJs supports this.closest(...) at SSR for tag-name selectors only, so the active or pressed state is marked in the first server paint, not only after hydration.

class UiTabsTrigger extends WebComponent({ value: String }) {
  get _tabs() { return this.closest('ui-tabs'); }

  render() {
    const active = this._tabs?.value === this.value;
    this.dataset.state = active ? 'active' : 'inactive';
    return html`<button data-state=${active ? 'active' : 'inactive'}><slot></slot></button>`;
  }
}
UiTabsTrigger.register('ui-tabs-trigger');

The SSR walker threads the chain of enclosing custom-element instances into each instance, and the server element shim's closest() resolves a parent over that chain (so this.closest('ui-tabs').value reads the live parent property the walker already applied). Host IDL properties a render() mutates on this (this.dataset.*, this.className, this.hidden, the aria* mixin) reflect to the matching attribute on the SSR'd host tag, so the active tab is marked before any JavaScript runs. The first client render produces the identical state (the browser's real closest() against the real DOM), so there is no hydration flash. Two limits apply.

  • Only tag-name selectors resolve at SSR (closest('ui-tabs')). A class, attribute, or descendant selector returns null server-side and resolves on the client. That covers the compound-component pattern, anything finer is client-only.
  • The compound parent must be light DOM (the default, and what every UI-kit compound component uses). A shadow-DOM parent projects its children through a native <slot>, and those slotted children are not threaded the SSR ancestor chain, so their closest(parent) resolves to null in the first server paint (it still resolves on the client after hydration). Keep compound parents light DOM for a correct first paint.

Genuine layout or live-DOM reads (querySelector, classList, attachShadow, geometry) still throw at SSR, so keep them in connectedCallback or firstUpdated. See Server-Side Rendering for the server element shim that backs this.

Fetching data in a component (async render)

A leaf component can fetch its own server data into the first paint, so you do not have to fetch it in the page and prop-drill it down. Make render() async and call a 'use server' action directly:

class UserProfile extends WebComponent({ uid: String }) {
  async render() {
    const u = await getUser(this.uid);   // real fn at SSR, RPC stub on the client
    return html`<h3>${u.name}</h3>`;
  }
}
UserProfile.register('user-profile');

SSR awaits the render, so the data is in the first paint with no fallback (JS-off reads it). On a client re-fetch (a prop change) the default is stale-while-revalidate: the prior content stays until the new render resolves. Define renderFallback() only to show a loading state DURING a re-fetch (never on the first paint). A thrown await is isolated to that component, with renderError() as the optional custom UI.

Which tool to reach for

  • Server data knowable at request time: async render() in the component. The default, simplest case.
  • Re-fetch where stale content would mislead: add renderFallback().
  • Genuinely client-only data (depends on a click, viewport, localStorage, or live updates, not needed in the first paint): use Task / signals plus an RPC action. A Task shows its pending state at SSR, so it loses first-paint data.
  • Slow server data where blocking the first byte hurts: stream it by wrapping the component in <webjs-suspense .fallback=${html`…`}> (fallback on the first byte, content streams in). See Data fetching.

Anti-patterns

  • Do NOT prop-drill server data through layers when the leaf component can fetch it itself.
  • Do NOT put await getData() in a page / layout function if it can live in a component (page fetches run sequentially, a route-level waterfall).
  • Do NOT fetch in connectedCallback / Task for data that is knowable server-side (that yields a fallback-then-RPC, not first-paint data).
  • Do NOT expect renderFallback() to affect the first paint, and do NOT add renderError() on every component (isolation is automatic).

Slots: Content Projection

Slots are how a parent passes content into a component. If you are coming from React, think of the default slot as children. WebJs supports the full shadow-DOM <slot> surface in light DOM as well as shadow DOM, so every example below works identically whether the component sets static shadow = true or leaves it at the default (light DOM). The light-DOM runtime mirrors HTMLSlotElement.assignedNodes(), assignedElements(), assignedSlot, and the slotchange event, plus named slots, fallback content, and first-wins resolution. To our knowledge no other web-components framework offers this complete parity in light DOM. Lit's slot APIs only work inside shadow roots, and Stencil's light-DOM slot polyfill has known gaps around fallback content and mixed shadow / non-shadow trees.

The native slot API, in light DOM

There is no WebJs-specific slot API. Light-DOM slots ARE the native DOM slot API, so post-mount writes are live exactly as in shadow DOM: appendChild, insertBefore, removeChild, el.remove(), innerHTML, flipping a child's slot="" attribute, and HTMLSlotElement.assign() all re-project immediately. The reads (assignedNodes(), assignedElements(), assignedSlot) and the slotchange event behave identically to shadow DOM, including native async-coalesced slotchange timing. One caveat rides assign(), which in light DOM is an extension (an element-bound overlay that works alongside name matching) while native shadow assign() only works under slotAssignment: 'manual', a mode WebJs's shadow side does not set. So assign() is the one write that does not survive flipping to static shadow = true; prefer slot="" attributes in a component meant to move between modes.

const card = document.querySelector('my-card');

// Every one of these is live, and identical to shadow DOM:
card.appendChild(node);
card.querySelector('[slot=old]').slot = 'new'; // re-projects
card.innerHTML = '<p>replaced</p>';

// Reads mirror shadow DOM:
card.querySelector('slot').assignedNodes();
node.assignedSlot;
card.querySelector('slot').addEventListener('slotchange', ...);

Migrating from a shadow-DOM component: flip static shadow and nothing else changes, with the documented gaps on this page as the exceptions (most notably the assign() caveat above and first-render read timing). You write the same template and the same imperative code.

The four light-DOM gaps, all a consequence of light DOM having no shadow boundary, not missing features:

  • Structural host reads. host.children / host.childNodes / host.querySelector(':scope > ...') and the innerHTML getter read the rendered template, not the authored children (in shadow DOM the authored children stay in the host's light tree). Read slotted content with assignedNodes() instead.
  • assignedChild.parentNode is the <slot> element, not the host.
  • ::slotted() CSS is shadow-only (it needs the boundary to select across). In light DOM, style slotted content with normal selectors or Tailwind on the children directly, which is strictly more powerful.
  • Initial-projection timing. The first light-DOM projection lands one microtask after the first render, so firstUpdated() sees the <slot> element with an empty assignedNodes() (shadow DOM projects natively before it). Read assigned content from a slotchange listener, or after a microtask. Every later read and every mutation-driven update is identical in both modes.

Conditional-on-slot at render time (branching a template on whether a slot has content) is not a thing in either mode, because a shadow template can't branch on light-child presence at render time either. Use CSS :has() / slot:empty, or a slotchange listener.

A generic DOM library that reaches into a component should operate on the assigned nodes, never on the host element itself.

Live writes need the component's JS on the page. A display-only slotted wrapper (a component that only renders a <slot>, with no interactivity) is elided, so it ships no JavaScript and its post-mount native writes are inert, the same as any elided component. A component that is actually interacted with ships automatically (a client module references its tag); if a consumer reaches an otherwise-display-only wrapper through a string selector the analyzer cannot see, force it to ship with static interactive = true. Shadow-DOM components always ship, so this is the one boundary set by elision rather than by slots.

Forwarded slots project their content everywhere. A template can forward a slot into a nested component (html`<inner-shell><slot></slot></inner-shell>`), and the outer component's content projects through it on a client-only mount, in the server-rendered first paint, and across hydration (it does not flash back to the fallback on the client). The renderer stamps each slot with the host whose template produced it, so a forwarded slot routes to the outer component that rendered it rather than the child it nests in.

A layout's named slots stay in sync across soft navigation. When a layout renders its ${children} inside a slotted shell and a page emits top-level slot=""-attributed children, the named-slot slices update on a soft-nav boundary swap just as the default slice does: the swap resyncs every slot of the enclosing shell from the incoming page.

Default Slot

The <slot></slot> element in a component's render() is where the parent's child content appears:

// Component definition
class AppShell extends WebComponent {
  // ...
  render() {
    return html`
      <header>My App</header>
      <main><slot></slot></main>
      <footer>Copyright 2026</footer>
    `;
  }
}

// Usage: the <p> is projected into <main>
html`
  <app-shell>
    <p>This paragraph appears inside the main slot.</p>
  </app-shell>
`;

This is how WebJs layouts work: the doc-shell and blog-shell components in the examples use a default <slot> to receive page content from the router.

Named Slots

Use <slot name="..."> to route different pieces of content to different parts of a component:

class PageLayout extends WebComponent {
  static styles = css`
    .sidebar { float: left; width: 200px; }
    .content { margin-left: 220px; }
    footer   { clear: both; border-top: 1px solid #ccc; padding-top: 16px; }
  `;

  render() {
    return html`
      <div class="sidebar">
        <slot name="sidebar"><em>No sidebar provided</em></slot>
      </div>
      <div class="content">
        <slot></slot>
      </div>
      <footer>
        <slot name="footer">Default footer content</slot>
      </footer>
    `;
  }
}
PageLayout.register('page-layout');

// Usage: assign content to named slots with the slot="" attribute
html`
  <page-layout>
    <nav slot="sidebar">
      <a href="/">Home</a>
      <a href="/about">About</a>
    </nav>

    <h1>Main Content</h1>
    <p>This goes into the default (unnamed) slot.</p>

    <small slot="footer">Custom footer here.</small>
  </page-layout>
`;

Content without a slot attribute goes to the default (unnamed) slot. Content with slot="name" is routed to the matching <slot name="name">. Text inside the <slot> tag itself is fallback content shown when no matching content is provided.

Lifecycle

WebJs components use the standard custom element lifecycle callbacks. If you override them, always call super.

connectedCallback()

Called when the element is inserted into the document. This is where WebJs attaches the shadow root, adopts styles, and performs the first render. Use it for setup work like fetching data, opening WebSocket connections, or reading from localStorage:

connectedCallback() {
  super.connectedCallback();  // REQUIRED: sets up shadow root + first render
  this._ws = connectWS('/api/chat', {
    onMessage: (msg) => this.messages.set([...this.messages.get(), msg]),
  });
}
Forgetting super.connectedCallback() is the #1 mistake. Without it, the component will never render.

disconnectedCallback()

Called when the element is removed from the document. Clean up event listeners, timers, WebSocket connections, and other resources:

disconnectedCallback() {
  this._ws?.close();
  this._ws = null;
  clearInterval(this._timer);
}

You do not need to call super.disconnectedCallback() (the base class is a no-op), but it does not hurt to include it for safety.

attributeChangedCallback(name, oldValue, newValue)

Called when one of the observedAttributes changes. WebJs handles this for you. It coerces the attribute value based on the type declared in the factory shape, sets the corresponding instance property, and schedules a re-render. You rarely need to override this, but you can if you need side effects when a specific attribute changes:

attributeChangedCallback(name, oldVal, newVal) {
  super.attributeChangedCallback(name, oldVal, newVal);
  if (name === 'src' && newVal !== oldVal) {
    this._loadImage(newVal);
  }
}

Render Is Automatic

You never call render() directly. It is called automatically:

  • Once during connectedCallback() (first paint).
  • After every signal write the render reads (tracked by the built-in SignalWatcher).
  • After every reactive-property assignment.
  • After every requestUpdate() call.
  • After every observed attribute change.

Events in Templates

Attach event listeners using the @event syntax in templates. This works like React's onClick, onSubmit, etc., but maps directly to DOM event names:

render() {
  return html`
    <button @click=${() => this.increment()}>Click me</button>
    <form @submit=${(e) => this.handleSubmit(e)}>
      <input @input=${(e) => this.onInput(e)} />
      <button type="submit">Send</button>
    </form>
  `;
}

How Event Binding Works

  • Server rendering: @event bindings are stripped during SSR. The HTML sent to the browser contains no inline handlers. This is safe, clean, and Content-Security-Policy friendly.
  • Client rendering: on the client, each @event binding creates a stable dispatcher function that is registered once with addEventListener. When you re-render with a new handler reference, the dispatcher is updated in place, so no listener is removed and re-added. This eliminates event listener churn that plagues naive re-render strategies.
// Even though this creates a new arrow function on every render,
// the actual addEventListener is only called once. The dispatcher
// swaps the inner handler reference behind the scenes.
render() {
  return html`
    <button @click=${() => this.count.set(this.count.get() + 1)}>
      ${this.count.get()}
    </button>
  `;
}

Properties vs Attributes in Templates

Templates support three binding prefixes for setting values on elements:

Regular Attributes: attr=${value}

Sets an HTML attribute. The value is stringified. If the value is null, undefined, or false, the attribute is removed.

html`<input type="text" value=${this.name} class=${this.active ? 'on' : 'off'} />`

Property Bindings: .prop=${value}

Sets a JavaScript property directly on the DOM element, bypassing attribute serialization. Use this when you need to pass objects, arrays, or other non-string values to a child component:

html`<my-chart .data=${this.chartData} .options=${{ animate: true }}></my-chart>`

On custom elements, property bindings round-trip through SSR. The renderer serializes the value via webjs's wire format (which handles Array, Object, Date, Map, Set, BigInt, and reference cycles) and emits it as a data-webjs-prop-* attribute. The SSR walker reads the attribute before calling render() so the component's first paint includes the bound value. On the client, connectedCallback applies and strips the attribute. End-to-end DX: html`<post-list .posts=${posts}></post-list>` in a page function just works, with rich types preserved.

On native elements (<input>, <button>, etc.), property bindings still drop at SSR. Native elements have no SSR walker that would consume the side-channel attribute, and the framework's HTML primitives already cover this case via the attribute form (value=${v}, checked=${b}). When the template runs in the browser (component render(), dynamic re-renders), the property is set normally. The property form is most useful for two-way controlled inputs via .value=${live(v)} paired with an @input handler.

Unserializable values (functions, class instances with private state, DOM nodes) drop at SSR with a single-line dev warning. The browser sees the property as undefined. Use @event=${fn} for callbacks or set imperatively in firstUpdated for client-only references.

Boolean Attributes: ?attr=${flag}

Adds the attribute if the value is truthy, removes it if falsy. This is the correct way to handle boolean HTML attributes like disabled, checked, hidden, and readonly:

html`
  <button ?disabled=${!this.connected.get()}>Send</button>
  <input ?checked=${this.agreed.get()} type="checkbox" />
  <div ?hidden=${this.items.get().length === 0}>No items</div>
`

During SSR, ?disabled=${true} emits disabled="" and ?disabled=${false} emits nothing, matching how the browser interprets boolean attributes.

Class.register('tag')

Register the component with Class.register('tag') at the bottom of the file:

MyCounter.register('my-counter');

WebJs wraps the native API (and installs a compatible shim on the server) so the same line works in both environments:

  • Browser: tells the browser to upgrade all <my-counter> elements with the MyCounter class, and mirrors the mapping into webjs's internal registry.
  • Server: stores the class in the internal registry so renderToString can look it up for Declarative Shadow DOM injection.

Module URLs for <link rel="modulepreload"> hints are discovered separately, by a server-side scanner that walks the app tree on the first request (memoized, and re-run after each rebuild) and derives the file path for each discovered tag. No per-component import.meta.url argument needed.

Always call Class.register at the module's top level, outside the class body. The component registers as soon as the module is imported, both on server and client.

The tag argument accepts any short-string quote style. register('my-counter'), register("my-counter"), and register(`my-counter`) are all equivalent. The framework's tag-name-has-hyphen lint rule reads the tag through any of them.

Server Rendering

WebJs components are server-rendered using Declarative Shadow DOM. When the server renders a page containing <my-counter count="5"></my-counter>, the output looks like:

<my-counter count="5">
  <template shadowrootmode="open">
    <style>
      :host { display: inline-flex; gap: 8px; }
      button { font: inherit; padding: 4px 12px; }
    </style>
    <button>-</button>
    <output>5</output>
    <button>+</button>
  </template>
</my-counter>

How SSR Works

  • The server imports the component module, which calls Class.register('tag') and stores the class in the registry.
  • During renderToString(), the server scans the output HTML for registered custom element tags.
  • For each match, it creates a temporary instance, applies attributes from the HTML, calls render(), and wraps the result in a <template shadowrootmode="open"> block with the component's styles.
  • The browser parses this as a native declarative shadow root, so the content is visible before any JavaScript loads.
  • When the component's JS module eventually loads and the custom element upgrades, the existing shadow root is reused. The client renderer performs a fine-grained diff against the already-painted DOM.

Async Rendering on the Server

On the server, render() can be async. This lets you fetch data inside a component:

class UserProfile extends WebComponent({ userId: String }) {
  constructor() {
    super();
    this.userId = '';
  }

  async render() {
    // This await is resolved during SSR, so the full HTML is sent to the client
    const user = await fetch(`/api/users/${this.userId}`).then(r => r.json());
    return html`
      <h2>${user.name}</h2>
      <p>${user.email}</p>
    `;
  }
}
UserProfile.register('user-profile');

On the client, render() is called synchronously. If you need async data on the client, fetch it in connectedCallback() and write a signal when the data arrives.

Fine-Grained Client Renderer

The client renderer does not rebuild the entire DOM on every state change. Instead, it tracks each dynamic "hole" in the template and only touches the parts that actually changed.

What Gets Preserved

  • Focus: if an <input> is focused when a signal write triggers a re-render, it stays focused.
  • Cursor position: the text cursor inside an input or textarea does not jump.
  • Selection: text selections survive re-renders.
  • Scroll position: scroll state of overflow containers is not disturbed.

This happens because the renderer only updates the specific text node, attribute, or property that changed. Elements that are not affected by the state change are never touched.

Template Caching

Templates are compiled once per unique strings array (the static parts of the tagged template). Because JavaScript engines intern tagged template string arrays, the same html`...` expression in a render() method produces the same strings identity on every call. This means:

  • The template is parsed into a <template> element and a list of part descriptors once.
  • On subsequent renders, the existing DOM is reused and only the changed values are applied.
  • If the template shape changes (e.g., a conditional returns a different html`...`), the old DOM is torn down and rebuilt.

Keyed Lists with repeat()

By default, rendering an array of templates rebuilds all children when any item changes. For lists where items have stable identities, use repeat() to enable keyed reconciliation:

import { WebComponent, html, css, repeat } from '@webjsdev/core';

class TaskList extends WebComponent {
  tasks = signal([
    { id: 1, text: 'Buy groceries', done: false },
    { id: 2, text: 'Write docs', done: true },
    { id: 3, text: 'Ship feature', done: false },
  ]);

  toggle(id) {
    this.tasks.set(this.tasks.get().map(t =>
      t.id === id ? { ...t, done: !t.done } : t
    ));
  }

  render() {
    return html`
      <ul>
        ${repeat(
          this.tasks.get(),
          (task) => task.id,           // key function: must be stable + unique
          (task) => html`
            <li @click=${() => this.toggle(task.id)}
                style=${task.done ? 'text-decoration: line-through' : ''}>
              ${task.text}
            </li>
          `
        )}
      </ul>
    `;
  }
}
TaskList.register('task-list');

How repeat() Works

  • Each item is identified by the key returned from the key function (first argument after items).
  • On re-render, items with matching keys update in place: the DOM nodes are reused, not recreated.
  • New keys cause fresh nodes to be inserted. Missing keys cause nodes to be removed.
  • When the order changes, existing DOM nodes are moved (via insertBefore), not destroyed and rebuilt. This preserves element identity, focus, scroll, and any internal state.
Use a stable ID from your data as the key, like task.id or user.email. Never use the array index as a key. It defeats the purpose of keyed reconciliation, just like in React.

On the server, repeat() is simply iterated in order. Keys are only used on the client for efficient DOM updates.

Putting It All Together

Here is a complete example showing properties, state, events, lifecycle, slots, and scoped styles in a single component:

import { WebComponent, html, css, repeat, connectWS } from '@webjsdev/core';

class ChatBox extends WebComponent {

  static styles = css`
    :host { display: block; border: 1px solid var(--border); border-radius: var(--rad-lg); }
    .log  { height: 200px; overflow-y: auto; padding: var(--sp-4); }
    .log p { margin: 0 0 var(--sp-2); }
    form  { display: flex; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border); }
    input { flex: 1; padding: var(--sp-2); border: 1px solid var(--border); border-radius: var(--rad); }
    button { padding: var(--sp-2) var(--sp-4); background: var(--accent); color: var(--accent-fg);
             border: 0; border-radius: var(--rad); cursor: pointer; }
  `;

  _conn = null;
  lines = signal([]);
  connected = signal(false);

  connectedCallback() {
    super.connectedCallback();   // always call super!
    this._conn = connectWS('/api/chat', {
      onOpen:    () => this.connected.set(true),
      onClose:   () => this.connected.set(false),
      onMessage: (msg) => {
        this.lines.set([...this.lines.get(), msg].slice(-50));
      },
    });
  }

  disconnectedCallback() {
    this._conn?.close();
    this._conn = null;
  }

  send(e) {
    e.preventDefault();
    const input = this.shadowRoot.querySelector('input');
    if (!input.value.trim() || !this._conn) return;
    this._conn.send({ text: input.value });
    input.value = '';
  }

  render() {
    const lines = this.lines.get();
    const connected = this.connected.get();
    return html`
      <div class="log">
        ${lines.length === 0
          ? html`<p><em>No messages yet.</em></p>`
          : repeat(lines, (l) => l.id, (l) => html`<p>${l.text}</p>`)}
      </div>
      <form @submit=${(e) => this.send(e)}>
        <input placeholder=${connected ? 'Say hi...' : 'Reconnecting...'}
               ?disabled=${!connected} autocomplete="off" />
        <button ?disabled=${!connected}>Send</button>
      </form>
    `;
  }
}
ChatBox.register('chat-box');

Quick Reference

  • Extend the WebComponent({ ... }) factory with your property shape (and optionally static styles for shadow-DOM components).
  • Implement render() returning html`...`.
  • Register with ClassName.register('tag-name') at the bottom of the file. Tag must contain a hyphen.
  • State: instance signals (foo = signal(...)) or reactive properties (the WebComponent({ ... }) factory, with prop() for options). Both feed the same batched re-render scheduler.
  • Events: @click, @submit, @input in templates. Stable dispatchers, no listener churn.
  • Bindings: attr=${v} for attributes, .prop=${v} for properties, ?bool=${v} for booleans.
  • Slots: <slot> for default content, <slot name="x"> for named slots, fallback content, assignedNodes(), slotchange. Works identically in light DOM and shadow DOM.
  • Light DOM by default. Set static shadow = true to opt in to shadow DOM for scoped styles (static styles = css`...`) or third-party embed isolation.
  • Lifecycle: connectedCallback() (call super!), disconnectedCallback(), attributeChangedCallback().
  • Lists: repeat(items, keyFn, templateFn) for efficient keyed updates.
  • SSR: components render to Declarative Shadow DOM. Async render() supported on the server.

Next Steps