Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

balon-js

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.

Table of Contents


Getting Started

npm install
npm run build        # builds all workspaces
npm run start:example # serves example/dist on http://localhost:8080 via docker

Minimal 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);

Core Concepts

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() and render().

State changes flow up as a request-render event, and the renderer fans a handle-render event back down to every component in scope.


Application Shell

<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.


Components

Building a Component

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.

Lifecycle

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.

Event Listeners

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 });
}

Extending Components

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.


Contexts and State

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;

Reading State

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.

Writing State

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 content

Setting 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);
}

Nested Contexts

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    // outer

Render 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.

Dependencies (Side Effects)

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 states array never re-runs after its initial call.
  • Errors thrown inside the function are caught and logged rather than breaking the render.

The Renderer

<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

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.

Navigating

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('/');
});

Nesting and Layout

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.

Server Configuration

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;
}

Notes and Limitations

  • 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).

Pages

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.


Component Library

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.

Loading State

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".

Theming

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;
}

Development

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:example

Each 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.

Project Layout

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.

License

MIT © Tristan Balon

About

A dependency-free frontend framework built on native Web Components, with DOM-scoped state contexts, focus-preserving rendering, a declarative router, and a matching UI component library.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages