Declarative Custom Elements concept implementation.
<define-element> <x-counter count:number="0"> <template> <button id="btn"> Clicks: <span id="n">0</span> </button> </template> <script> this.onconnected = () => { let n = this.querySelector('#n') this.querySelector('#btn').onclick = () => n.textContent = ++this.count } </script> </x-counter> </define-element> <!-- use it --> <x-counter></x-counter> <x-counter count="10"></x-counter>
Declare types inline — count:number="0". Auto-detected when untyped. Changes reflect back to attributes.
Scoped automatically. Light DOM uses CSS nesting, shadow DOM isolates fully. :host works in both.
this is the element. Runs once per instance. Connect, disconnect, prop change callbacks.
Plug in any template engine — sprae, Alpine, petite-vue, template-parts — or wire DOM yourself.
Attributes are strings, so a type suffix like :number coerces the value for you. Skip the suffix and the type is guessed from the value. Primitives reflect back to attributes; array and object don't.
<x-widget count:number="0" label:string="Click me" active:boolean > <script> this.count // 0 (number) this.label // "Click me" (string) this.active // false (boolean) this.count = 5 // reflect to attribute this.getAttribute("count") // "5" </script> </x-widget>
| Type | Coercion | Default |
|---|---|---|
| :string | String(v) |
"" |
| :number | Number(v) |
0 |
| :boolean | true unless "false" |
false |
| :date | new Date(v) |
null |
| :array | JSON.parse(v) |
[] |
| :object | JSON.parse(v) |
{} |
| (none) | auto-detect | as-is |
<style> is scoped automatically. Without shadow DOM, :host rewrites to the tag name and styles nest under it. With shadow DOM, styles are fully isolated and shared across instances.
<script> runs once per instance at creation. this is the element. onconnected and ondisconnected fire on attach/detach; onpropchange fires when a property changes.
<define-element> <x-clock time:string> <template> <time id="t"></time> </template> <script> let id // template DOM not ready here const tick = () => this.time = new Date().toLocaleTimeString() // added to DOM — template is ready this.onconnected = () => { t = this.querySelector('#t') tick(); id = setInterval(tick, 1000) } // removed from DOM this.ondisconnected = () => clearInterval(id) // property changed → update DOM this.onpropchange = () => t.textContent = this.time </script> </x-clock> </define-element>
Use any framework instead of wiring DOM manually. Set DE.processor to configure any template engine — reactive bindings replace querySelector boilerplate.
The W3C declarative proposal has stalled for years over template syntax disagreements. The polyfill attempts are mostly dead. So you either write boilerplate or avoid custom elements.
<define-element> fills the gap: include the script and write components as HTML. Pick any template engine or skip it entirely.
It's ~2KB (200 loc). If the W3C proposal ships natively, this becomes unnecessary.