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-bindelements, 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 structuresif they differ (or throws, ifjsRibbon.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="..."alongsidecomponent:Nameto 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'stextContentdata-bind="value:age"withvalue="25"— reads from the input's value attribute- Checkbox
checkedattribute becomestrue/falsein 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
factory receives ($state, el) and
should return a context object with event handlers.true (default), the MutationObserver automatically
registers new components as they enter the DOM.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>