The Tiny, Object-Based Web Component Library.
Drow is a minimalist wrapper for the Web Components API. It replaces the boilerplate of JavaScript Classes with a clean, object-based configuration. Define your components as simple objects and let Drow handle the registration and rendering.
- 🚫 Zero Dependencies: No NPM, no build steps, no headaches.
- 📉 Microscopic Size: Tiny footprint with high performance.
- 🧩 Object-First API: No more
class X extends HTMLElementorsuper(). - ⚡ Native Performance: Uses the browser's built-in Custom Elements registry.
- 🔁 Batched Rendering: Rapid state mutations are coalesced into a single DOM update per frame.
Include drow.js in your HTML file:
<scriptsrc="drow.js"></script>Or as an NPM module:
npm install drowjsimportDrowfrom'drowjs';Drow components are defined using simple objects. Reactivity is handled automatically via the state object and methods.
constconfig={name: "my-counter",state: {count: 0},css: `button { font-weight: bold; }`,template: ` <div> <button @click="increment">Count is {{count}}</button> </div> `,methods: {increment(){this.state.count++;}}};Drow.register(config);| Key | Type | Description |
|---|---|---|
name | string | Required. Custom element tag name — must contain a hyphen. |
template | string | HTML template string. Supports {{expression}} interpolation. |
state | object | Reactive per-instance state. Changes trigger a batched re-render. |
methods | object | Event handler functions. Called with this bound to the component. |
computed | object | Derived values: { key: (state) => value }. Available in templates. |
props | string[] | Observed HTML attributes, exposed as template variables. |
css | string | Scoped CSS. Use :host to target the component root. |
shadow | boolean | Attach a Shadow DOM instead of rendering in the Light DOM. |
useStore | boolean | Subscribe to the global store. Access values via {{store.key}}. |
init | function | Lifecycle hook — called once after the component connects. |
disconnected | function | Lifecycle hook — called when the component is removed from the DOM. |
updated | function | Lifecycle hook — called after every render cycle. |
watch | function(attr) | Called when an observed prop attribute changes. Receives { name, oldValue, newValue, comp }. |
| Directive | Description | Example |
|---|---|---|
@event | Bind DOM events. Supports .prevent and .stop modifiers. | @click="doSomething" |
@event.prevent | Calls preventDefault() before the handler. | @submit.prevent="save" |
@event.stop | Calls stopPropagation() before the handler. | @click.stop="select" |
d-model | Two-way binding for inputs. Supports nested paths. | d-model="user.name" |
d-for | Render a list. Supports objects and (item, index) syntax. | d-for="item in items" |
d-key | Keyed diffing for d-for — patches only changed items. | d-key="{{item.id}}" |
d-if | Conditional rendering — removes element from DOM. | d-if="isVisible" |
d-else-if | Chained condition after d-if. | d-else-if="otherFlag" |
d-else | Fallback block after d-if or d-else-if. | d-else |
d-show | Toggles display: none — element stays in DOM. | d-show="isVisible" |
d-class:name | Conditionally adds/removes a CSS class. | d-class:active="isActive" |
d-bind:attr | Reactive attribute binding. | d-bind:src="imageUrl" |
d-html | Sets innerHTML from a state value. Use with trusted content only. | d-html="richContent" |
d-ref | Registers element in this.refs for direct DOM access. | d-ref="myInput" |
d-cloak | Hidden until the component finishes rendering (prevents FOUC). | d-cloak |
Use {{key}} in templates to render state, computed, prop, or store values. Dot notation is supported for nested access.
<p>{{user.profile.name}}</p><p>{{store.currentUser}}</p>Bind any DOM event with @eventName="methodName". Modifiers can be chained with .:
<button@click="save">Save</button><form@submit.prevent="handleSubmit">...</form><div@click.stop="select">...</div>d-model syncs an input's value with state. It supports nested paths:
<inputd-model="username" /><inputd-model="address.city" /><inputtype="checkbox" d-model="settings.darkMode" />Iterate over arrays of primitives or objects:
<!-- Primitive list --><lid-for="tag in tags">{{tag}}</li><!-- Object list --><lid-for="user in users">{{user.name}} — {{user.email}}</li><!-- With index --><lid-for="(item, index) in items">{{index}}. {{item}}</li>Add d-key to enable efficient patching — Drow will reuse existing DOM nodes instead of rebuilding the entire list on every render:
<lid-for="item in items" d-key="{{item.id}}">{{item.name}}</li>Use a value that uniquely identifies each item (e.g. an id). Without d-key, the list is rebuilt from scratch on each render.
<pd-if="status === 'loading'">Loading…</p><pd-else-if="error">Something went wrong.</p><pd-else>Ready.</p>d-else-if and d-else must immediately follow a d-if (or another d-else-if) element with no elements between them.
Use d-show instead of d-if when the element should remain in the DOM and only its visibility should toggle:
<divd-show="isPanelOpen">...</div>Computed values derive from state and are re-evaluated on every render:
Drow.register({name: "cart-summary",state: {items: []},computed: {total: (state)=>state.items.reduce((sum,item)=>sum+item.price,0),isEmpty: (state)=>state.items.length===0},template: ` <div> <p d-if="isEmpty">Your cart is empty.</p> <p d-else>Total: ${{total}}</p> </div> `});Drow.register({name: "live-clock",state: {time: ""},init(){// Called once after the component connects to the DOMthis._timer=setInterval(()=>{this.state.time=newDate().toLocaleTimeString();},1000);},disconnected(){// Called when the component is removed from the DOMclearInterval(this._timer);},updated(){// Called after every render cycleconsole.log("clock re-rendered:",this.state.time);},template: `<span>{{time}}</span>`});Share state across multiple components with Drow.store. Any component with useStore: true re-renders when the store changes.
// WriteDrow.store.state.user="Alice";// Read in template// {{store.user}}Drow.register({name: "user-badge",useStore: true,template: `<span>Logged in as {{store.user}}</span>`});Drow.register({name: "login-btn",useStore: true,methods: {login(){Drow.store.state.user="Alice";}},template: `<button @click="login">Log In</button>`});Components can dispatch custom events that bubble up the DOM:
methods: {submit(){this.emit("form-submit",{data: this.state.formData});}}Listen from a parent:
document.querySelector("my-form").addEventListener("form-submit",(e)=>{console.log(e.detail.data);});Access DOM elements directly via this.refs:
<inputd-ref="emailInput" type="email" /><button@click="focusEmail">Focus</button>methods: {focusEmail(){this.refs.emailInput.focus();}}CSS defined in css is automatically scoped to the component. Use :host to target the component element itself:
css: ` :host { display: block; padding: 1rem; } button { background: #a78bfa; color: white; }`For Shadow DOM components (shadow: true), standard :host CSS works natively.
Declare observed attributes with props. They're available as template variables and trigger a re-render when changed. Use watch to respond to changes:
Drow.register({name: "user-card",props: ["username","avatar"],watch({ name, oldValue, newValue }){console.log(`${name} changed: ${oldValue} → ${newValue}`);},template: ` <div> <img d-bind:src="avatar" /> <p>{{username}}</p> </div> `});<user-cardusername="Alice" avatar="alice.png"></user-card>Drow.debug controls console output. It defaults to true. Set it to false before registering components in production to suppress all Drow logs and dev warnings:
Drow.debug=false;Drow.register({ ... });In debug mode, Drow will warn you if:
- A component name is missing or doesn't contain a hyphen (required by the Custom Elements spec)
- An unknown key is present in a config object
This example demonstrates list rendering, two-way data binding, and event handling.
Drow.register({name: "todo-app",state: {newTask: "",tasks: ["Master Drow.js","Build a tiny app"]},template: ` <div class="todo-box"> <h3>Task List ({{count}})</h3> <input d-model="newTask" placeholder="Add a new task..."> <button @click="addTask">Add</button> <button @click="clearTasks">Clear All</button> <ul> <li d-for="(task, index) in tasks" d-key="{{task}}"> <span>{{index}}. {{task}}</span> <button @click="removeTask" data-item="{{task}}">x</button> </li> </ul> </div> `,computed: {count: (state)=>state.tasks.length},methods: {addTask(){if(this.state.newTask.trim()){this.state.tasks=[...this.state.tasks,this.state.newTask];this.state.newTask="";}},removeTask(e){constitem=e.target.dataset.item;this.state.tasks=this.state.tasks.filter(t=>t!==item);},clearTasks(){this.state.tasks=[];}},css: ` .todo-box { border: 1px solid #444; padding: 1rem; border-radius: 8px; } input { padding: 5px; border-radius: 4px; border: 1px solid #ccc; } button { cursor: pointer; background: #a78bfa; color: white; border: none; padding: 5px 10px; border-radius: 4px; } li { display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; } li button { background: #ef4444; padding: 2px 8px; } `});Check out the live interactive demos:
Detailed API documentation is generated using JSDoc. To generate it locally:
npm run docThe documentation is generated in the docs/ directory.
npm install
npm run servernpx terser drow.js -o drow.min.js --compress --mangleAuthor johnfacey.dev
