Skip to content

Custom Elements

A custom element is a class that extends HTMLElement, registered with a tag name. The browser instantiates it whenever the parser encounters that tag.

class GreenSquare extends HTMLElement {
connectedCallback() {
this.style.background = 'limegreen'
this.style.inlineSize = '40px'
this.style.blockSize = '40px'
this.style.display = 'inline-block'
}
}
customElements.define('green-square', GreenSquare)
<green-square></green-square>

That’s a component. It’s in the browser. There is no framework, no build step, and the entire definition is 30 lines you can read.

Lifecycle. connectedCallback, disconnectedCallback, attributeChangedCallback, adoptedCallback. The browser calls these when the element joins the DOM, leaves it, has an observed attribute change, or moves between documents. No framework needed.

Encapsulation. Attach a shadow root and your styles can’t leak out, parent styles can’t leak in. Slot composition works the way it does in HTML — nest content, project it, style slotted content with ::slotted().

class GreenCard extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid limegreen; }
::slotted(h2) { color: limegreen; margin: 0 0 0.5rem; }
</style>
<slot></slot>
`
}
}
customElements.define('green-card', GreenCard)
<green-card>
<h2>This is green</h2>
<p>The h2 above is colored without ever knowing it's inside a card.</p>
</green-card>

Upgrades. When the parser meets <green-card>, the browser will upgrade it the moment its definition arrives — even if the element was on the page before the script loaded. This is why custom elements have no hydration step. The element is the component.

Composition with native elements. A custom element can wrap, extend, or be a real native element. <kit-button> puts a real <button> inside. <kit-dialog> puts a real <dialog> inside. You inherit the platform’s keyboard handling, focus handling, form participation, screen reader semantics, and constraint validation — for free.

You can write custom elements in plain JS and many people do. Lit is the smallest reasonable abstraction on top of the platform’s component model. It gives you:

  • Decorator-based reactive properties: @property() name = 'world' re-renders when assigned.
  • Tagged templates that compile to efficient DOM updates: html`<p>${this.name}</p>` .
  • A way to express conditional and list rendering without JSX or virtual DOM diffing.
  • Roughly 6 KB gzipped.
import { LitElement, html } from 'lit'
import { property } from 'lit/decorators.js'
export class GreenCounter extends LitElement {
@property({ type: Number }) count = 0
render() {
return html`
<p>Count: ${this.count}</p>
<button @click=${() => this.count++}>+</button>
`
}
}
customElements.define('green-counter', GreenCounter)

You write the component. The browser runs it. There’s no virtual DOM. There’s no reconciler. There’s no hydration step.

Kitsune ships custom elements (built with Lit) for the application primitives that every app needs and none of them should have to write:

  • <kit-shell> — the application root, owns the runtime
  • <kit-boundary> — a semantic context zone (surface, feature, entity)
  • <kit-button>, <kit-dialog>, <kit-toast-region>, <kit-field>, <kit-card> — accessible UI primitives
  • More to follow

You’re free to write your own custom elements alongside them. Kitsune doesn’t own your component vocabulary; it owns the coordination vocabulary.

Save this as an HTML file. Open it. Click the button.

<!doctype html>
<script type="module">
import { LitElement, html } from 'https://esm.sh/lit'
class TodoItem extends LitElement {
static properties = { done: { type: Boolean, reflect: true } }
constructor() { super(); this.done = false }
render() {
return html`
<label>
<input type="checkbox" .checked=${this.done}
@change=${(e) => this.done = e.target.checked} />
<slot></slot>
</label>
`
}
}
customElements.define('todo-item', TodoItem)
</script>
<todo-item>Write a frontend without React</todo-item>
<todo-item done>Survive doing it</todo-item>

Two custom elements, in plain HTML, talking to the platform. No build step. Save them, ship them, run them in 2034.

Next: Dialog and Popover →