Overview

jsRibbon is a lightweight, dependency-free DOM component and data-binding library for progressive enhancement. It attaches reactive behavior to server-rendered HTML using data-bind attributes — treating the DOM as both the markup and the authoritative state container.

Unlike virtual DOM frameworks, jsRibbon performs targeted direct DOM mutations with zero diffing overhead. It's designed for server-rendered apps where the initial HTML is meaningful, SEO matters, and you want to add interactivity without a heavy build pipeline.

Installation

One file, no bundler, no npm install required. Two ways to get it, depending on whether you want a CDN dependency or not.

CDN

Points at the latest tagged release on jsDelivr — nothing to download or host yourself.

<script src="https://cdn.jsdelivr.net/gh/raheelshan/jsRibbon@latest/dist/jsRibbon.min.js"></script>

Pin a version for production so an upstream release can't change your app underneath you — replace @latest with a tag like @v1.0.0.

Direct / self-hosted

Download dist/jsRibbon.min.js (or the unminified dist/jsRibbon.js) from the repo, put it next to your HTML, and reference it with a relative path. No CDN dependency, works fully offline.

<script src="./jsRibbon.js"></script>

Quick Start

Register a component, then add markup. jsRibbon initializes on DOMContentLoaded and auto-picks up components injected via AJAX.

<script>
  jsRibbon.component('MyCounter', ($state) => {
    const increment = () => $state.count++;
    const decrement = () => $state.count--;
    return { increment, decrement };
  });
</script>

<div data-bind="component:MyCounter">
  <span data-bind="text:count">0</span>
  <button data-bind="click:increment">+</button>
  <button data-bind="click:decrement">−</button>
</div>

Components

A component is two halves that must both be present: a factory function registered in JS, and a data-bind="component:Name" marker on the DOM element that factory should apply to. Register the factory with jsRibbon.component(name, factory) — it receives the reactive $state (and the root element itself) and returns a context object of methods your markup can call via click:/other event bindings.

<script>
  jsRibbon.component('UserCard', ($state, el) => {
    // el is this component instance's root DOM element
    const save = () => {
      console.log('Saving:', $state.name);
    };
    return { save };
  });
</script>

<div data-bind="component:UserCard">
  <input data-bind="value:name" value="Raheel" />
  <button data-bind="click:save">Save</button>
</div>

Multiple instances of the same component can exist on a page — each component:UserCard element gets its own independent $state, scoped to that one DOM subtree.

Why a component root is required

The component: element is the reactive scope — it's what jsRibbon creates $state for, and the boundary it uses to decide which data-bind elements belong together. Every other binding type (text:, value:, foreach:, event handlers, everything) reads and writes that one component's state, so without a component: root there's no state object for a binding to attach to in the first place — it isn't optional scaffolding, it's the thing that makes the rest of the bindings work at all.

Bindings outside a component scope are silently skipped — no console warning

If a data-bind element has no component: ancestor anywhere above it, jsRibbon doesn't throw, and — despite what you might expect from reading the source — it doesn't log anything to the console either. It just never gets registered, so it stays completely inert: no initial value applied, no reactivity, no event handling, and no error to tell you why. This is the most common reason a binding "does nothing" with zero explanation: if a binding is inert, check that it actually has a component: ancestor before suspecting a typo in the binding syntax itself.

<!-- Missing the component: wrapper — silently does nothing, no warning either -->
<span data-bind="text:name">Raheel</span>

Two more component-scoping details worth knowing:

  • All instances of the same named component must share the same inner HTML structure (same data-bind elements, same nesting) — jsRibbon compares each new instance's markup against the first one it saw and logs ❌ JS Ribbon Error: Component "Name" detected with multiple markup structures if they differ (or throws, if jsRibbon.hardFail = true). This exists because jsRibbon has no template engine of its own — the DOM your server rendered is the template, so every instance needs to actually be that template.
  • Add data-key="..." alongside component:Name to prevent the same logical instance from being registered twice — useful when the same markup might be scanned more than once (e.g. an HTMX swap that re-sends a fragment already on the page).

State & Reactivity

jsRibbon reads the initial state directly from the DOM. Text content, input values, and checked states become reactive state keys automatically.

  • data-bind="text:name" — reads initial value from element's textContent
  • data-bind="value:age" with value="25" — reads from the input's value attribute
  • Checkbox checked attribute becomes true/false in state

State is a JavaScript Proxy. Setting any property on $state triggers all subscribed DOM bindings to update automatically.

Prefer value: over text: for anything that isn't pure display

text: is read-only — it shows state but nothing on the page can change it. value: is two-way and also type-aware: a checkbox seeds a real boolean, and <input type="number"> seeds an actual JavaScript number via parseFloat. A text:-bound value is always seeded as a plain string, even if it looks numeric — so if a handler is going to do arithmetic on a key ($state.qty + 1) or the user should be able to edit it, seed it from an <input data-bind="value:qty">, not a <span data-bind="text:qty">.

If two elements target the same key — say a display <span data-bind="text:price"> and an editable <input data-bind="value:price"> elsewhere in the same component — the input's value wins for the initial state, regardless of which element appears first in the markup. Avoid relying on this by only ever seeding a given key from one binding; if you do need both a display and an edit control for the same value, bind the display with text: to the same key the input uses and let the input own the initial value.

MutationObserver

jsRibbon installs a MutationObserver on document.body at startup. Any HTML injected into the DOM (from AJAX, innerHTML, HTMX swaps, etc.) is automatically scanned and initialized — no manual rebind needed.

// Example: inject markup 2 seconds later, it just works
setTimeout(() => {
  document.getElementById('container').innerHTML = `
    <div data-bind="component:UserCard">
      <span data-bind="text:name">Raheel</span>
    </div>
  `;
}, 2000);

Composition

A child component can read a parent component's state, or call one of its methods, using a dotted key: the part before the dot names the parent component, title-cased. There's no separate attribute to declare the relationship — jsRibbon resolves it on the fly by walking up the DOM from the element doing the binding, looking for the nearest ancestor whose data-bind contains component: followed by that title-cased name.

jsRibbon.component('Layout', ($state) => {
  const save = () => console.log('Saving:', $state.title);
  return { save };
});
jsRibbon.component('Header', () => ({}));

<div data-bind="component:Layout">
  <span data-bind="text:title">Dashboard</span>

  <div data-bind="component:Header">
    <!-- reads the Layout component's state, three levels up or one — doesn't matter -->
    <h1 data-bind="text:layout.title"></h1>
    <!-- calls the Layout component's save() method -->
    <button data-bind="click:layout.save">Save</button>
  </div>
</div>

layout.title works because layout title-cases to Layout, matching component:Layout on the ancestor — the child component doesn't need to be a direct child, and doesn't need its own matching name. There's no depth limit and nothing to import; any dotted key resolves the same way, whether it's a text:/value:/class:/attr: read or a click: (or other event) method call.

If the named ancestor isn't found, jsRibbon logs a console warning and leaves that one binding inert rather than breaking the rest of the page — check the component name and its title-casing first (e.g. userList must match component:UserList, not component:Userlist).

text binding

Binds a state key to an element's textContent. Initial value is read from the DOM.

<div data-bind="component:Profile">
  <span data-bind="text:name">Raheel Shan</span>
  <!-- $state.name starts as "Raheel Shan" -->
</div>

Like every binding, text: only works inside a component: root — register one even if it has no methods: jsRibbon.component('Profile', () => ({})).

value binding

Two-way binding for inputs, textareas, and selects. By default it updates state on input for text/number fields and on change for checkboxes/radios — add update:eventName to override with any DOM event name, not just a fixed set. update:change waits for blur/commit instead of every keystroke; update:blur or update:focusout work the same way for inputs that should only update when the user leaves the field.

<div data-bind="component:Profile">
  <input type="text" data-bind="value:name, update:input" value="Raheel" />
  <span data-bind="text:name"></span>
</div>

Native HTML attributes keep working

jsRibbon binds to the input you already wrote — it doesn't replace or reimplement the control, so every native HTML attribute or property the browser already understands (maxlength, pattern, step, required, placeholder, autocomplete, accept, and so on) keeps behaving exactly as normal HTML, with or without a data-bind next to it.

min/max on <input type="number"> get one step further than "left alone": jsRibbon actively reads them and clamps the reactive value to that range on every change — not just when the form is submitted, which is all native HTML5 validation gives you for free (a browser will happily let someone type 999 into a field with max="100" and only flag it as :invalid at submit time). With jsRibbon, $state is clamped immediately.

<input type="number" min="0" max="100" data-bind="value:percent" />
<!-- typing 150 immediately becomes $state.percent === 100, not just on submit -->

class binding

Conditionally toggles CSS classes based on state. Uses stateKey => className syntax.

<div data-bind="component:BoxDemo">
  <div data-bind="class: [ isHidden => hidden, isPink => pink ]">
    content
  </div>
</div>

attr binding

Reactively sets HTML attributes from state. Uses stateKey => attrName syntax.

<div data-bind="component:LinkDemo">
  <a data-bind="attr: [ linkUrl => href, linkTitle => title ]">Click</a>
</div>

html binding

Sets innerHTML of an element from state. Useful for rendering rich text from a textarea.

<div data-bind="component:RichTextDemo">
  <textarea data-bind="value:content, update:blur"></textarea>
  <div data-bind="html:content"></div>
</div>

Security: this sets innerHTML with no sanitization. Fine for content you or your editors control (like the rich-text case above); never bind html: straight to unsanitized user input — that's a stored-XSS hole.

visible binding

Shows or hides an element by toggling display:none based on a boolean state key.

<div data-bind="component:PanelDemo">
  <input type="checkbox" data-bind="value:isOpen" />
  <div data-bind="visible:isOpen">
    Shown when checkbox is checked
  </div>
</div>

foreach binding

Renders a list from an array in state. jsRibbon gets the row template one of two ways, depending on whether the server already rendered rows or not.

Mode 1 — server already rendered the rows

If the first child inside the foreach container is real markup (not a <template> tag), jsRibbon does two things with it: it reads every existing child's bound values into the initial array (hydrating your server-rendered rows in place, no re-render), and it keeps that first child around as the clone source for any rows added later. This is the mode to use whenever the server can render the list itself — which is most of the time in a server-rendered app.

<script>
  jsRibbon.component('Users', ($state) => ({
    add: () => $state.users.push({ firstName: 'New', age: 25 })
  }));
</script>

<!-- foreach: (like every binding) only works inside a component: root -->
<div data-bind="component:Users">
  <tbody data-bind="foreach: [ data: users, as: user ]">
    <tr>
      <td data-bind="text:firstName">Raheel</td>
      <td data-bind="text:age">40</td>
      <td><button data-bind="click:removeItem">Remove</button></td>
    </tr>
    <!-- any additional <tr> siblings here are hydrated the same way -->
  </tbody>
</div>

Mode 2 — no rows to render yet (empty state)

When there's genuinely no server data yet — a brand-new list, a "your cart is empty" state, anything rendered entirely on the client — there's no real row for jsRibbon to read as the template, so give it a <template> tag instead. jsRibbon reads the markup inside the <template> to learn what one row looks like, removes the <template> tag itself from the live DOM (it's never rendered), and starts the list at zero items. Nothing appears until you push something into the array — the <template> is purely a markup source, not a placeholder row.

<script>
  jsRibbon.component('Notifications', ($state) => ({
    push: (msg) => $state.notifications.push({ message: msg, unread: true })
  }));
</script>

<div data-bind="component:Notifications">
  <ul data-bind="foreach: [ data: notifications, as: note ]">
    <template>
      <li data-bind="class: [ unread => is-unread ]">
        <span data-bind="text:note.message"></span>
        <button data-bind="click:removeItem">Dismiss</button>
      </li>
    </template>
    <!-- empty until $state.notifications gets pushed to — no placeholder row shows up here -->
  </ul>
</div>

Either mode works with the same data:/as: config and the same reactive array behavior below — the only difference is where the row markup comes from and whether the list starts populated or empty.

Arrays are proxied — calling push(), splice(), shift(), etc. reuses existing row elements by position and only patches what changed, rather than rebuilding the list.

Rows aren't limited to text/click — every binding type works inside a row, including a two-way value: on an editable cell. The write is resolved against that row's item at the moment the input fires, so it stays correct even if the row is later reused for a different item after a splice().

<div data-bind="component:Cart">
  <tbody data-bind="foreach: [ data: cart, as: item ]">
    <tr data-bind="class: [ lowStock => low-stock ]">
      <td data-bind="text:name">Widget</td>
      <td><input type="number" min="0" data-bind="value:qty"></td>
      <td><button data-bind="click:removeItem">Remove</button></td>
    </tr>
  </tbody>
</div>

Event Bindings

Any DOM event can be bound using eventName:handlerName syntax. Handlers receive the event and a parsed data-* dataset object.

<script>
  jsRibbon.component('ClickCheck', ($state) => ({
    handleClick: (event, data) => {
      console.log(data.id);   // 42 (auto-parsed)
      console.log(data.user); // {name:"Raheel"} (parsed JSON)
    }
  }));
</script>

<div data-bind="component:ClickCheck">
  <button
    data-bind="click:handleClick"
    data-id="42"
    data-user='{"name":"Raheel"}'
  >Click</button>
</div>

Supported events: click, dblclick, mouseenter, mouseleave, keydown, keyup, input, change, focus, blur, and more.

Checkbox & Radio

Single checkboxes bind to a boolean. Multiple checkboxes with the same key bind to an array. Radio buttons bind to the selected value string.

<div data-bind="component:PreferencesForm">
  <!-- Single checkbox -->
  <input type="checkbox" data-bind="value:accepted" />

  <!-- Checkbox group (binds to array) -->
  <input type="checkbox" value="HTML" data-bind="value:skills" />
  <input type="checkbox" value="CSS"  data-bind="value:skills" />

  <!-- Select All toggle: checks/unchecks every value: checkbox in this component that shares the "skills" key -->
  <input type="checkbox" data-bind="toggle:skills" />

  <!-- Radio buttons -->
  <input type="radio" name="gender" value="male"   data-bind="value:gender" checked />
  <input type="radio" name="gender" value="female" data-bind="value:gender" />
</div>

jsRibbon rewrites each radio's name internally to name_<componentId> the first time it binds it, so identically-named radio groups in separate component instances on the same page don't fight over the same selection. This only affects the live DOM's name attribute — value:gender keeps working exactly as written; you'll just see the suffixed name if you inspect the element.

jsRibbon API

jsRibbon.component(name, factory)
Register a component. factory receives ($state, el) and should return a context object with event handlers.
jsRibbon.getInstances(name)
Returns an array of all mounted DOM elements for a given component name.
jsRibbon.scanAndRegister(root)
Programmatically scan a subtree and register any uninitialized components.
jsRibbon.scanAndUnregister(root)
Remove component registrations for a subtree (used internally by the MutationObserver).
jsRibbon.unregister(el)
Remove a single element from the component registry.
jsRibbon.reset()
Clear all registries and rescan the document. Useful for hot-reloading scenarios.
jsRibbon.autoRegister
Boolean. When true (default), the MutationObserver automatically registers new components as they enter the DOM.
jsRibbon.hardFail
Boolean. When true, throws an error if the same component name is found with inconsistent markup. Default: false (soft warn).

Forms & HTMX

jsRibbon intentionally does not ship a bespoke AJAX form helper. For form enhancement, use HTMX alongside jsRibbon. HTMX handles declarative XHR and content swapping; jsRibbon's MutationObserver automatically hydrates any new markup that HTMX injects.

<!-- HTMX posts the form, jsRibbon auto-hydrates the response -->
<form hx-post="/submit" hx-swap="innerHTML" hx-target="#result">
  <input name="email" type="email" />
  <button>Submit</button>
</form>
<div id="result"></div>