A small frontend framework built directly on native Web Components. No virtual DOM, no build-time template compiler, no runtime dependencies — components are real custom elements that render themselves with innerHTML, and state lives in context elements placed in the DOM.
This repository is an npm workspace with three packages:
| Package | Description |
|---|---|
balon-js |
The framework: Component, Context, Renderer, Router, Page. |
components |
A UI component library (ui-button, ui-card, ui-input, …) built on the framework. |
example |
A working demo app that uses both. |
- Getting Started
- Core Concepts
- Application Shell
- Components
- Contexts and State
- The Renderer
- Routing
- Pages
- Component Library
- Development
npm install
npm run build # builds all workspaces
npm run start:example # serves example/dist on http://localhost:8080 via dockerMinimal app:
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/index.js" type="module"></script>
</head>
<body>
<base-renderer>
<base-context data-name="default">
<base-router data-route="/">
<home-page></home-page>
</base-router>
</base-context>
</base-renderer>
</body>
</html>import "balon-js";
import HomePage from "./pages/home/home-page.js";
customElements.define('home-page', HomePage);Four custom elements make up the framework, and they nest in a fixed order:
<base-renderer> renders components, preserves focus
<base-context> holds state
<base-router> shows/hides its children based on the URL
<base-page> static page structure
<your-component> a Component subclass
- Renderer — owns the render cycle. Every component must be a descendant of one.
- Context — a keyed state store. Components read and write state through it; writing state asks the renderer to re-render everything inside that context.
- Router — swaps its children in and out of the DOM based on
window.location.pathname. - Component — your UI. Implements
enter()andrender().
State changes flow up as a request-render event, and the renderer fans a handle-render event back down to every component in scope.
<base-renderer> must wrap everything. <base-context> requires a data-name, which is the key components use to reach it:
<base-renderer>
<base-context data-name="default">
<ui-navbar>
<ui-navbar-item data-type="link" data-route="/">Home</ui-navbar-item>
<ui-navbar-item data-type="link" data-route="/profile">Profile</ui-navbar-item>
</ui-navbar>
<base-router data-route="/">
<home-page></home-page>
</base-router>
<base-router data-route="/profile">
<profile-page></profile-page>
</base-router>
</base-context>
</base-renderer>A component that isn't inside a <base-renderer> throws on connect.
Extend Component and implement enter() and render(), then register the class as a custom element.
import { Component } from "balon-js";
export default class CounterDisplay extends Component {
enter() {
// Runs once, before the first render.
}
render() {
const ctx = this.contexts.default;
const count = ctx.getState('counter', 0);
this.innerHTML = `
<ui-card>
<ui-card-header><span>Counter</span></ui-card-header>
<ui-card-body>
<ui-bold>Clicked ${count} times.</ui-bold>
</ui-card-body>
</ui-card>
`;
}
}
customElements.define('counter-display', CounterDisplay);Then use it anywhere inside the renderer:
<counter-display></counter-display>render() is called on connect and again on every state change in scope. Treat it as a pure function of state: read what you need, write the markup, wire up listeners.
| Member | Description |
|---|---|
enter() |
Called once, before the first render. Set up initial state, dependencies, and long-lived listeners here. Required. |
render() |
Called after enter(), and again on every render request in scope. Required. |
this.contexts |
Map of data-name → context element, built from the component's ancestors. |
this.renderer |
The nearest <base-renderer>. |
this.signal |
An AbortSignal that fires when the component leaves the DOM. |
setDependency(context, fn, states) |
Runs fn when any of the named state values change. See Dependencies. |
enter() fires once per component instance, even if the element is disconnected and reconnected (which is what the router does when navigating). Event listeners bound to the renderer are re-bound on every reconnect and dropped on disconnect.
render() replaces innerHTML, so listeners on your own markup must be attached at the end of every render():
render() {
const ctx = this.contexts.default;
const count = ctx.getState('counter', 0);
this.innerHTML = `
<ui-button class="increment-btn" data-key="increment" data-type="primary">
Increment (${count})
</ui-button>
`;
this.querySelector('.increment-btn').addEventListener('click', () => {
ctx.setState('counter', count + 1);
});
}For listeners on anything that outlives the component — window, document, the renderer, a parent element — pass this.signal so they are torn down automatically:
enter() {
window.addEventListener('resize', () => this.handleResize(), { signal: this.signal });
}Subclassing works normally — a base class can define shared markup or behaviour and let children fill in the rest. The components package does exactly this with ComponentElement, which syncs a whitelist of attributes onto an inner native element:
import { Component } from "balon-js";
export default class ComponentElement extends Component {
static Attributes = { };
element = null;
connectedCallback() {
super.connectedCallback(); // always call super
this.#syncAttributes();
}
static get observedAttributes() {
return Object.values(this.Attributes);
}
attributeChangedCallback(name, oldValue, newValue) {
this.element?.setAttribute(name, newValue);
}
}A concrete component built on it:
import ComponentElement from "../element.js";
export default class Button extends ComponentElement {
static Attributes = Object.freeze({
Disabled: 'disabled',
Form: 'form',
Type: 'type',
});
#key = null;
enter() {
// Hand the key to the inner <button> so focus restoration targets the real control.
this.#key = this.dataset.key;
delete this.dataset.key;
}
render() {
this.innerHTML = `<button data-key="${this.#key ?? ''}">${this.innerHTML}</button>`;
this.element = this.querySelector('button');
}
focus(args) {
this.element?.focus(args);
}
}If you override connectedCallback() or disconnectedCallback(), always call super — the base class resolves the renderer, builds the context map, and manages the abort controller there.
A context is a <base-context> element with a data-name. It stores a flat key/value map and notifies the renderer when a value changes.
<base-context data-name="default">
...
</base-context>Components find it by name through this.contexts:
const ctx = this.contexts.default;getState(name, defaultValue = undefined)const count = ctx.getState('counter', 0);
const name = ctx.getState('name'); // undefined if unset
const isBusy = ctx.getState('isLoading', false);getState falls back to defaultValue when the value is null or undefined.
setState(name, value, triggerRender = true, escapeValue = true)| Parameter | Description |
|---|---|
name |
State key. |
value |
New value. |
triggerRender |
false to update quietly without asking for a re-render. |
escapeValue |
true (default) HTML-escapes string values. Pass false to store raw. |
ctx.setState('counter', 5); // updates and re-renders
ctx.setState('name', input.value); // escaped by default
ctx.setState('payload', json, false); // no re-render
ctx.setState('markup', html, true, false); // stored unescaped — only for trusted contentSetting a value identical to the current one is a no-op: no event, no render. Because render() writes innerHTML directly, values are escaped on the way in rather than on the way out — leave escapeValue on unless you are deliberately storing markup you control.
reset() clears the entire context.
Common patterns:
// Increment
const current = ctx.getState('counter', 0);
ctx.setState('counter', current + 1);
// Clear
ctx.setState('name', undefined);
// Toggle
ctx.setState('isLoading', !ctx.getState('isLoading'));
// Batch: write quietly, then let the last write trigger one render
ctx.setState('firstName', 'Ada', false);
ctx.setState('lastName', 'Lovelace');Set initial state from enter() with triggerRender as false — the first render happens right after enter() anyway:
enter() {
const ctx = this.contexts.carousel;
ctx.setState('currentIdx', ctx.getState('currentIdx', 0), false);
}Contexts nest. this.contexts is built by walking up the ancestors, so a component sees every context above it, keyed by name:
<base-context data-name="default">
<base-context data-name="carousel">
<ui-carousel-content></ui-carousel-content> <!-- sees both -->
</base-context>
</base-context>this.contexts.carousel // nearest
this.contexts.default // outerRender scope follows the context that changed: a write to carousel re-renders only components inside that context, while a write to default re-renders everything beneath it. Nest a context around a noisy subtree to keep its updates local.
setDependency runs a function when specific state values change — the framework's equivalent of a watched effect. Register it from enter():
setDependency(context, fn, states = [])enter() {
const ctx = this.contexts.default;
this.setDependency(ctx, () => {
ctx.setState('isLoading', true);
fetch(`/api/users/${ctx.getState('name')}`)
.then(resp => resp.json())
.then(data => ctx.setState('profile', data, false))
.catch(error => console.error("Unable to fetch profile.", error))
.finally(() => ctx.setState('isLoading', false));
}, ['name']);
}Behaviour:
- The function runs immediately on registration, then again whenever any listed state value changes (compared with
!==against the previous snapshot). - Values are checked before each render, so a dependency fires ahead of the
render()that shows its result. - Registering the same context and function twice is ignored, so calling it from
enter()is safe. - A dependency with an empty
statesarray never re-runs after its initial call. - Errors thrown inside the function are caught and logged rather than breaking the render.
<base-renderer> listens for request-render from any context below it and dispatches handle-render to the components in scope. It also preserves focus and caret position across a render.
Because render() throws away and rebuilds the DOM, the renderer needs a stable identity for the focused element. Give every focusable element a data-key, unique within the renderer:
<ui-input type="text" name="name" data-key="name"></ui-input>
<ui-button data-key="submit">Submit</ui-button>After the render, the renderer re-focuses [data-key="…"] and restores selectionStart / selectionEnd / selectionDirection if the element exposes setSelectionRange. A focused element without a data-key logs a warning and loses focus on the next render.
Custom elements that wrap a native control should forward focus() (and setSelectionRange() for text inputs) to the inner element, as ui-input does.
Routing is declarative. Each <base-router> owns one path via data-route and shows its children only when window.location.pathname matches:
<base-router data-route="/">
<home-page></home-page>
</base-router>
<base-router data-route="/profile">
<profile-page></profile-page>
</base-router>When a route doesn't match, its children are removed from the DOM but kept in memory, then reinserted on the way back — so component instances (and anything set up in enter()) survive navigation, while disconnectedCallback tears down their listeners while they're off-screen.
Router.navigate() is static and can be called from anywhere:
import { Router } from "balon-js";
Router.navigate('/profile');It pushes the path with history.pushState and tells every router on the page to re-check its route. From a component:
render() {
this.innerHTML = `<ui-button class="profile-btn" data-key="profile">Profile</ui-button>`;
this.querySelector('.profile-btn').addEventListener('click', () => {
Router.navigate('/profile');
});
}Save state before navigating away — context state is independent of the router, so it persists across page changes:
formElem.addEventListener('submit', () => {
const formData = new FormData(formElem);
ctx.setState('name', formData.get('name'), false);
Router.navigate('/');
});Routers live inside a context, so pages share state through it. Anything placed outside the routers — a navbar, a footer — stays mounted across navigation:
<base-context data-name="default">
<ui-navbar>
<ui-navbar-item data-type="link" data-route="/">Home</ui-navbar-item>
<ui-navbar-item data-type="link" data-route="/profile">Profile</ui-navbar-item>
</ui-navbar>
<base-router data-route="/"><home-page></home-page></base-router>
<base-router data-route="/profile"><profile-page></profile-page></base-router>
</base-context>ui-navbar-item calls Router.navigate() on click and marks itself with data-active="true" when its data-route matches the current path.
Deep links must fall back to index.html, since routes are resolved client-side. The example's nginx config:
location / {
try_files $uri $uri/ /index.html;
}- Routes are matched by exact pathname. There is no pattern matching, no path parameters, and no wildcard/404 route — pass values through context state instead.
- A path with no matching router simply renders nothing.
- Routers respond to
Router.navigate(); they do not currently re-check on browser back/forward (popstate).
Page is a lighter element for static structure. It renders once, on first connect, and never re-renders — ideal for laying out the components a route is made of:
import { Page } from "balon-js";
import CounterControls from "./components/counter-controls.js";
import CounterDisplay from "./components/counter-display.js";
export default class HomePage extends Page {
render() {
this.innerHTML = `
<ui-layout>
<ui-space data-direction="vertical" data-gap="medium">
<counter-controls></counter-controls>
<counter-display></counter-display>
</ui-space>
</ui-layout>
`;
}
}
customElements.define('counter-controls', CounterControls);
customElements.define('counter-display', CounterDisplay);Pages have no contexts map, no dependencies, and no enter() — they are structure. Put anything that reacts to state in a Component.
The components package registers a set of UI elements. Import it once at the entry point:
import "components";
import "components/styles.css";| Element | Notes |
|---|---|
ui-layout |
Centred, max-width page container. |
ui-space |
Flex container. data-direction="vertical|horizontal", data-gap="extra-small|small|medium|large|extra-large", data-justify="start|center|end", data-wrap="wrap|wrap-reverse", data-type="inline". |
ui-card, ui-card-header, ui-card-body, ui-card-footer |
Card container and sections. |
ui-button |
data-type="primary|secondary|danger", plus disabled, type, form. |
ui-input |
type, name, value, disabled. Exposes value, focus(), setSelectionRange(). |
ui-label |
Wraps its first text node as the label text, followed by the control. |
ui-navbar, ui-navbar-content, ui-navbar-item |
Navigation bar; items take data-route and navigate on click. |
ui-carousel, ui-carousel-slide |
Wrapping carousel with its own internal carousel context and generated controls. |
ui-bold, ui-italic, ui-underline |
Typography wrappers. |
Any element carrying data-loading="true" renders its supported descendants as a shimmering skeleton and blocks pointer events. Drive it from state:
render() {
const isLoading = this.contexts.default.getState('isLoading', false);
this.innerHTML = `
<ui-card ${isLoading ? 'data-loading="true"' : ''}>
<ui-card-body>
<ui-bold>${this.contexts.default.getState('name', '')}</ui-bold>
</ui-card-body>
</ui-card>
`;
}Opt an individual child out with data-loading="false".
Styling is driven by CSS custom properties defined on :root in components/src/tokens.css. Override any of them in your own stylesheet:
:root {
--ui-primary-background-color: #101215;
--ui-layout-max-width: 1100px;
--ui-font-family: "Inter", sans-serif;
--ui-padding: 16px;
}npm run build # build every workspace
npm run watch # watch balon-js
npm run build:components # build the component library
npm run watch:components
npm run build:example # build the example app
npm run watch:example
npm run start:example # serve example/dist on :8080 (docker + nginx)
npm run stop:exampleEach package is bundled with esbuild. balon-js is marked external in the component library build, so the framework is only ever bundled once by the consuming app.
balon-js/
├── balon-js/src/ framework (component, context, renderer, router, page)
├── components/src/ UI library — element.js base class + components/
└── example/src/ demo app — pages/<page>/components/
The example follows a page-per-route layout: each page owns a directory containing its *-page.js and a components/ folder for the components it renders.
MIT © Tristan Balon