Quiet Burrow
Quiet Burrow™ is an optional utility that lets you add interactivity to various parts of a webpage without taking over the entire DOM. It gives you framework-like features without the framework so you can sprinkle in reactivity only where you actually need it.
What is a burrow? Jump to heading
Think of a burrow as an interactive "island" that lives in your page. Create reactive templates with a familiar declarative syntax, add state, and respond to events…all with just a few lines of code.
Here's an obligatory counter example. No build step or transpilation is required.
<!-- Host --> <div id="counter"></div> <!-- Burrow --> <script type="module"> import { burrow, state, html } from '/dist/burrow.js'; const data = state({ count: 0 }); burrow('counter', () => html` <button @click=${() => data.count++}> Clicks: ${data.count} </button> `); </script>
Burrows are designed to be authored directly in HTML but can also be imported. You can put multiple burrows on the page and keep them all in sync without making the rest of the page reactive, giving you fast, efficient updates without the overhead or complexity of a framework.
When to use Burrow: If you find yourself reaching for a framework just to add a handful of interactive elements to an otherwise static page, Burrow is probably a good fit.
When not to use Burrow: If you're building a single-page application or if your app requires routing, stores, and complex framework features, a framework is probably better.
Installation Jump to heading
Burrow can be installed via CDN or npm. Use these copy-and-paste examples to get started.
import { burrow, state, html } from 'https://cdn.jsdelivr.net/npm/@quietui/quiet-browser@1.6.1/dist/burrow.js'; const data = state({ name: 'Whiskers' }); burrow('ELEMENT_ID_HERE', () => html` <p>Hello, ${data.name}!</p> `);
import { burrow, state, html } from '@quietui/quiet/burrow.js'; const data = state({ name: 'Whiskers' }); burrow('ELEMENT_ID_HERE', () => html` <p>Hello, ${data.name}!</p> `);
Creating your first burrow Jump to heading
Every burrow needs a host element, which is where the burrow will be attached. The host can be
virtually any HTML element on the page. This example creates a burrow and attaches it to the
#greeting element. All it does at the moment is render a paragraph.
<!-- Host --> <div id="greeting"></div> <!-- Burrow --> <script type="module"> import { burrow, html } from '/dist/burrow.js'; burrow('greeting', () => html` <p>Hello, world!</p> `); </script>
Now let's add a text field that controls who we're greeting. We'll store the value in a state object so the
DOM will automatically update when it gets modified. Inside the template, we'll use @input to
respond to the input event when the user types something.
<!-- Host --> <div id="greeting"></div> <!-- Burrow --> <script type="module"> import { burrow, html, state } from '/dist/burrow.js'; // Create a state object const data = state({ name: 'world' }); // Render a text field, bind it to state, and update // it when the `input` event fires burrow('greeting', () => html` <input type="text" placeholder="Enter your name" .value=${data.name} @input=${event => data.name = event.target.value} /> <br> <p>Hello, ${data.name}!</p> `); </script>
Note that we use .value to bind to the text field's value property instead of its
attribute. This will be discussed more later on.
Authoring templates Jump to heading
Burrow uses
lit-html
under the hood, which gives you a powerful and efficient templating system based on platform APIs. Templates
are written using
tagged template literals
via html.
Template syntax Jump to heading
The basic syntax for creating a template is straightforward. Just wrap your HTML in backticks and prefix it
with html.
import { html } from '/dist/burrow.js'; const template = html` <div class="card"> <h2>Title</h2> <p>Content goes here</p> </div> `;
You can interpolate values anywhere in your template using ${expression}.
const name = 'Alice'; const count = 42; html` <div> <p>Hello, ${name}!</p> <p>Count: ${count}</p> </div> `;
Templates can include any JavaScript expression inside ${}, making it easy to show or hide
content based on a value.
html` ${data.isLoggedIn ? html`<p>Welcome back!</p>` : html`<p>Please log in</p>` } `);
Use logical operators for simpler conditionals.
html` ${data.count > 0 && html` <p>You have ${data.count} items</p> `} `;
You can also use expressions for calculations or formatting.
const data = state({ price: 29.99 }); html` <p>Total: $${(data.price * 1.1).toFixed(2)}</p> `;
Binding attributes and properties Jump to heading
There are three ways to bind values in templates, each serving a different purpose.
String attributes — Use the normal syntax to set string attributes.
html`<img src=${imageUrl} alt=${description} />`;
Boolean attributes — Use the ? prefix for attributes that should be present or
absent based on a boolean.
html`<button ?disabled=${isLoading}>Submit</button>`;
Properties — Use the . prefix to set element properties directly. This is
especially useful for form controls.
html`<input .value=${currentValue} />`;
Always use .value for form controls instead of the value attribute. The
attribute only sets the initial value, while the property controls the current value.
Listening to events Jump to heading
Listen to events using the @ prefix followed by the event name.
const data = state({ count: 0 }); burrow('app', () => html` <button @click=${() => data.count++}> Increment </button> `);
You can access the event object in the callback.
html` <input @input=${event => { console.log('New value:', event.target.value); }} > `;
Handlers can be moved to functions outside the template to keep code organized.
function handleClick() { console.log('The click has been handled'); } burrow('app', () => html` <button @click=${handleClick}>Click me</button> `);
Event listeners are automatically cleaned up when the burrow detaches.
Using directives Jump to heading
Burrow exports some useful directives from lit-html that handle common patterns.
classMap
— Conditionally apply CSS classes.
import { html, classMap } from '/dist/burrow.js'; const isActive = true; const hasError = false; html` <div class=${classMap({ active: isActive, error: hasError })}> Content </div> `;
ifDefined
— Only sets an attribute if the value is defined (not null or undefined).
import { html, ifDefined } from '/dist/burrow.js'; const userId = null; const userName = 'Alice'; html` <div data-user-id=${ifDefined(userId)} data-user-name=${ifDefined(userName)} > User Info </div> `; // Result: only data-user-name attribute is set
live
— Checks the live DOM value and only updates if different, useful for inputs where the user might be typing.
import { html, live } from '/dist/burrow.js'; const data = state({ value: 'initial' }); html` <input .value=${live(data.value)} @input=${e => data.value = e.target.value} /> `; // The input won't reset cursor position during typing
repeat
— Efficiently render lists with keys.
import { html, repeat } from '/dist/burrow.js'; const items = [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' } ]; html` <ul> ${repeat( items, // array to repeat (item) => item.id, // a unique key (item) => html`<li>${item.name}</li>` // render each item )} </ul> `;
The repeat directive is more efficient than mapping arrays when items can be reordered,
added, or removed, since it preserves DOM elements by key instead of recreating them.
styleMap
— Apply inline styles from an object.
import { html, styleMap } from '/dist/burrow.js'; const styles = { color: 'red', fontSize: '16px' }; html`<p style=${styleMap(styles)}>Styled text</p>`;
unsafeHTML
— Renders a string as HTML without escaping.
import { html, unsafeHTML } from '/dist/burrow.js'; const htmlContent = '<strong>Bold</strong> and <em>italic</em> text'; html` <div> ${unsafeHTML(htmlContent)} </div> `; // Renders the HTML tags instead of escaping them
unsafeSVG
— Renders a string as SVG without escaping.
import { html, unsafeSVG } from '/dist/burrow.js'; const svgContent = '<circle cx="50" cy="50" r="40" fill="blue" />'; html` <svg width="100" height="100"> ${unsafeSVG(svgContent)} </svg> `; // Renders the SVG elements directly
until
— Renders placeholder content until a Promise resolves.
import { html, until } from '/dist/burrow.js'; const fetchUser = fetch('/api/user').then(r => r.json()); html` <div> ${until( fetchUser.then(user => html`<p>Hello, ${user.name}!</p>`), html`<p>Loading...</p>` )} </div> `; // Shows "Loading..." until the promise resolves
Additional Directives — Burrow exports only the most commonly used directives, but you can
use any of
lit-html's directives
in a template by importing them directly from
lit-html.
Stylesheets Jump to heading
Elements inside burrows will inherit styles as you'd expect. You can also add styles to a template using the
<style> element. Burrow does not encapsulate styles, but you can easily scope them to the
host element using
nested CSS:
#your-host-id { /* Host element styles */ background: light-dark(#f3f4f6, #1e293b); /* Nested styles only apply to elements inside the host */ h1, p { color: light-dark(#111827, #f1f5f9); } }
Working with state Jump to heading
State objects are reactive so, when you modify them, all burrows using the state will automatically re-render. A state's scope is effectively the same scope as the variable you assign it to. This means you have full control over keeping state objects local and/or sharing them with other burrows.
Creating state Jump to heading
State objects are created by passing an object with default values to the state() function:
import { state } from '/dist/burrow.js'; const data = state({ count: 0, name: 'Alice', isLoading: false });
You can read and write properties just like a normal object:
console.log(data.count); // 0 data.count = 5; console.log(data.count); // 5
When you modify a state property, all attached burrows will update to reflect the new state. Updates are batched, so multiple changes in a single tick only trigger one re-render.
State objects are shallow reactive, meaning only direct properties trigger updates. If you modify nested objects or arrays, you'll need to reassign them for the change to be detected.
Sharing state across burrows Jump to heading
State objects can be shared between multiple burrows, making it easy to keep different parts of the UI in sync without making the entire page reactive.
<!-- Host elements --> <div id="input"></div><br> <!-- ... --> <div id="display"></div> <!-- Burrows --> <script type="module"> import { burrow, html, state } from '/dist/burrow.js'; // Shared state const data = state({ message: `I'd far rather be happy than right any day` }); // First burrow with input burrow('input', () => html` <input placeholder="Enter a message" .value=${data.message} @input=${event => data.message = event.target.value} /> `); // Second burrow displaying the same data burrow('display', () => html` <p>Message: ${data.message}</p> `); </script>
In this example, state is scoped to the module and, since both burrows are defined in the same module, both burrows have access to the same state. To share state across burrows defined in separate modules, export the state from one and import it into another.
You can even create a separate module just for shared state, if you prefer to organize it that way.
// // data.js // import { state } from '/dist/burrow.js'; export const sharedData = state({ message: `Freedom's just another word for nothing left to lose` }); // // input.js // import { burrow, html } from '/dist/burrow.js'; import { sharedData } from './data.js'; burrow('input', () => html` <input .value=${sharedData.message} @input=${event => sharedData.message = event.target.value} /> `); // // display.js // import { burrow, html } from '/dist/burrow.js'; import { sharedData } from './data.js'; burrow('display', () => html` <p>Message: ${sharedData.message}</p> `);
Lifecycle hooks Jump to heading
Burrow provides lifecycle hooks that run when a burrow is attached to and detached from the DOM. Use the
third argument to provide callbacks for attached and detached. This is a great
place to setup and tear down observers, timers, WebSocket connections, etc.
burrow('app', () => html`<p>Content</p>`, { attached() { console.log('Burrow attached to:', this.host); }, detached() { console.log('Burrow detached'); } });
Inside lifecycle hooks, this refers to the
burrow instance.
Organizing burrows Jump to heading
There are two main approaches to organizing your burrows: inlining and importing. For simple, one-off instances, use an inline burrow. If you plan to use the same burrow on more than one page, or if you want to separate complex logic for clarity, use an imported burrow.
Inline burrows Jump to heading
Inline burrows are defined directly in your HTML. This approach is perfect for one-off widgets or page-specific functionality.
<div id="app"></div> <script type="module"> import { burrow, html, state } from '/dist/burrow.js'; const data = state({ count: 0 }); burrow('app', () => html` <button @click=${() => data.count++}> Count: ${data.count} </button> `); </script>
Imported burrows Jump to heading
For complex burrows or those used across multiple pages, create them in separate JavaScript files and export them.
// counter.js import { burrow, html, state } from '/dist/burrow.js'; const data = state({ count: 0 }); export const counter = burrow(() => html` <button @click=${() => data.count++}> Count: ${data.count} </button> `);
Then you can import and manually attach them to your page.
<div id="counter"></div> <script type="module"> import { counter } from './counter.js'; counter.attach('counter'); </script>
Manual attachment Jump to heading
By default, burrows automatically attach when you provide a host ID or reference as the first argument. Sometimes you need more control over when a burrow is attached or detached.
Create a burrow without auto-attaching by omitting the host parameter. Since we're using the imperative API, we'll need to retain a reference to the burrow instance.
const myBurrow = burrow(() => html` <p>Content</p> `);
Now you can attach it manually.
myBurrow.attach('app');
You can also detach a burrow, which removes it from the DOM and cleans up all state tracking.
myBurrow.detach();
After detaching, you can reattach the burrow to the same or a different element.
myBurrow.attach('different-element');
Each burrow instance can only be attached to one element at a time. If you call attach() on a
burrow that's already attached, it will first detach from its current location. By design, you cannot
attach the same burrow instance to multiple elements.
The Burrow instance Jump to heading
When you create a burrow, you get back a burrow instance with the following properties and methods.
Here's the API documentation formatted as a markdown table:
| Method/Property | Description |
|---|---|
host |
A reference to the DOM element the burrow is attached to, or null if it's not attached.
Useful for checking attachment state or accessing the host element directly.
|
attach(element) |
Attaches the burrow to a DOM element. Accepts either a string ID or an element reference. If the burrow is already attached elsewhere, it will detach first. |
detach() |
Removes the burrow from the DOM and cleans up all state tracking and event listeners. |
update() |
Manually triggers a re-render of the burrow. This is rarely needed since state changes automatically trigger updates, but can be useful when integrating with external libraries or when you need to force a refresh. Returns a promise that resolves after the DOM has been fully updated. |
Using TypeScript Jump to heading
Burrow is written in TypeScript and includes type definitions out of the box. Here are some tips to improve your experience if you happen to be using TypeScript.
To strongly type state objects, define an interface and pass it as a type parameter.
interface AppData { count: number; name: string; items: string[]; } const data = state<AppData>({ count: 0, name: 'Meowy', items: [] });
Event handlers aren't inferred, but you can assign them as shown.
burrow('app', () => html` <input @input=${(event: InputEvent) => { const target = event.target as HTMLInputElement; data.name = target.value; }} /> `);
Antipatterns Jump to heading
- Do not nest burrows. Burrows are not components and shouldn't be used as such; here be [unsupported] dragons
- Do not try to use a burrow more than once on the same page; the need for this is a strong sign that you should componentize the functionality instead
- Avoid using refs and/or surgically changing the DOM within a burrow; let the template do the work
- Avoid building apps that require routing, complex state management; use a framework instead
Building apps with Burrow Jump to heading
It's not a recommendation of the maintainer to use Burrow to build complex applications. However, if you were to find yourself on such an adventure, here are some tips for a successful quest:
MPA vs. SPA Jump to heading
The web started off with multi-page applications (MPA) and then single-page applications became all the rage (SPA). Now the world seems split between the two patterns.
For this purpose, the maintainer recommends an MPA, as no router is provided. The browser provides a file-based one for free anyways! Plus, there's something to be said about the simplicity of a clean slate on every page load.
Consider using Hotwire:Turbo or View Transitions for a more SPA-like experience.
Managing state Jump to heading
Create an App State file and only store in it the data that must be global to the app. Keep all other state local. Anything that doesn't need to be exposed outside of a burrow shouldn't be exposed outside of a burrow.
This strict separation makes understanding, maintaining, and debugging easier since each burrow acts as an independent module, only poking through the module barrier to interact with the App State.
Use ES modules to abstract and share functionality. Instead of writing the same logic and copying it multiple times, consider importing it into each burrow that needs it. State and other objects can also be shared, making it easy to reference the same data across multiple burrows.
Minimizing dependencies Jump to heading
Sometimes it's necessary to import third-party dependencies into a burrow, but each dependency comes at a cost. As a developer, you must carefully consider that cost. Try to use lean, laser-focused libraries instead of large, complex ones that try to do everything.
As for your own code, Burrow's philosophy encourages minimal JavaScript. This keeps load times fast and reactivity focused only where it needs to be for the best possible performance.
But again, the maintainer doesn't recommend Burrow for complex applications. Should you choose to swim upstream, I would love to hear about your experience regardless of how it goes. Feel free to post in the public forum.