v1.0 — zero dependencies

The DOM is your
markup & state.

jsRibbon attaches reactive behavior to server-rendered HTML using data-bind attributes — no virtual DOM, no compiler, no build step.

quick-start.html
<!-- 1. Include the library -->
<script src="jsRibbon-component-state.js"></script>
<script src="jsRibbon.js"></script>

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

<!-- 3. Write HTML — DOM is the state -->
<div data-bind="component:Counter">
  <span data-bind="text:count">0</span>
  <button data-bind="click:increment">+</button>
  <button data-bind="click:decrement"></button>
</div>

Built different.
no VDOM · no compiler · no framework

A lightweight approach to reactive UIs that respects your server-rendered HTML.

🧩

Component Based

Register named components with jsRibbon.component(). Each instance is scoped and isolated.

🏗️

DOM is the Markup

Server-rendered HTML is your template. No JSX, no .vue files — just HTML with data-bind attributes.

💾

DOM is the State

The live DOM is the source of truth. Initial values are read directly from element content and values.

🔗

Composition

Components can access parent contexts via dotted names like layout.users.

🚫

No JavaScript Required

Basic bindings like text, value, class, and visible work with zero JS — just HTML attributes.

Auto-detects AJAX HTML

MutationObserver watches for injected HTML and registers components automatically — no manual rebind.

🔍

SEO Preserved

Content is present in the initial HTML for crawlers. jsRibbon hydrates in place without replacing markup.

🧠

Persistent Methods

Component methods registered before markup arrives stay alive — they activate when elements appear.

Install in 30 seconds.

No bundler required. Include the scripts in order and start binding.

<script src="jsRibbon-component-state.js"></script>
<script src="jsRibbon.js"></script>
git clone https://github.com/raheelshan/jsRibbon.git

The scripts must be loaded in order: jsRibbon-component-state.js before jsRibbon.js.

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

Include the two library files in order. No bundler, no npm install required.

<!-- Required: state helpers must come first -->
<script src="./jsRibbon-component-state.js"></script>
<script src="./jsRibbon.js"></script>

<!-- Optional: AJAX form enhancement -->
<script src="./jsRibbon-enhance-form.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

Components are the primary unit of organization. Register a factory function using jsRibbon.component(name, factory). The factory receives the reactive $state and returns a context object with event handlers.

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

Multiple instances of the same component can exist on a page — each gets its own scoped state.

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.

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

text binding

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

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

value binding

Two-way binding for inputs, textareas, and selects. Supports update to control when state updates.

ModifierTrigger
update:inputOn every keystroke
update:changeOn blur / change
update:focusoutOn focus out
(default)input event for text, change for checkbox/radio
<input type="text" data-bind="value:name, update:input" value="Raheel" />
<span data-bind="text:name"></span>

class binding

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

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

attr binding

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

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

html binding

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

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

visible binding

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

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

foreach binding

Renders a list from an array in state. Server-rendered rows with data-key are hydrated in place. New items are cloned from a <template> or the first existing child.

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

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

Arrays are proxied — calling push(), splice(), shift(), etc. automatically re-renders only the changed rows.

Event Bindings

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

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

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

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.

<!-- 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 -->
<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" />

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>

Live Examples

Interactive demos powered by jsRibbon — every demo uses the actual library.

Text Binding

Bind state keys to element text content. Initial values are read directly from the DOM.

 text-binding.html
<div data-bind="component:Greeter">
  <input
    data-bind="value:name, update:input"
    value="World"
  />
  <p>Hello,
    <span data-bind="text:name">
    </span>!
  </p>
</div>
Live demo
Hello, !

Value / Input Binding

Two-way binding for text inputs, number spinners, and select dropdowns.

 value-binding.html
<div data-bind="component:AgeSelector">
  <input type="number"
    min="0" max="120" value="25"
    data-bind="value:age, update:input"
  />
  <span data-bind="text:age"></span>
</div>

<div data-bind="component:CityPicker">
  <select data-bind="value:city">
    <option value="">Select</option>
    <option value="Karachi">Karachi</option>
  </select>
  <span data-bind="text:city"></span>
</div>
Live demo
Number input
Age:
Select dropdown
Selected:

Class Binding

Conditionally apply CSS classes based on reactive state values.

 class-binding.html
<style>
  .box-highlighted { border-color: #2BEBA0 !important; }
  .box-large { transform: scale(1.1); }
</style>

<div data-bind="component:BoxCtrl">
  <label>
    <input type="checkbox"
      data-bind="value:isGlowing"/>
    Highlight
  </label>
  <label>
    <input type="checkbox"
      data-bind="value:isBig"/>
    Scale
  </label>
  <div
    data-bind="class: [ isGlowing => box-highlighted, isBig => box-large ]"
    class="demo-box"
  >Reactive Box</div>
</div>
Live demo
Reactive Box

Visible Binding

Show or hide elements by toggling display:none based on a boolean state key.

 visible.html
<div data-bind="component:ToggleDemo">
  <label>
    <input type="checkbox"
      data-bind="value:isVisible"
    /> Show panel
  </label>
  <div data-bind="visible:isVisible">
    <p>
      This panel is controlled
      by the checkbox above!
    </p>
  </div>
</div>
Live demo
✓ This panel appears when the checkbox is checked!

Event Bindings

Bind DOM events to component methods. data-* attributes are automatically parsed and passed as the second argument.

 events.html
jsRibbon.component('EventDemo', ($state) => {
  const handleClick = (event, data) => {
    $state.lastClicked = data.label;
    $state.price = data.price;
  };
  return { handleClick };
});

<div data-bind="component:EventDemo">
  <button
    data-bind="click:handleClick"
    data-label="Apple"
    data-price="1.2"
  >Apple $1.20</button>
  <button
    data-bind="click:handleClick"
    data-label="Mango"
    data-price="2.5"
  >Mango $2.50</button>
</div>
Live demo
Selected: none — $0

Foreach Binding

Render lists from arrays. Push/splice methods trigger automatic re-renders.

 foreach.html
jsRibbon.component('UserList', ($state) => ({
  add: () => $state.users.push({
    firstName: 'New User', age: 25
  })
}));

<div data-bind="component:UserList">
  <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"
      ></button></td>
    </tr>
  </tbody>
  <button data-bind="click:add">
    Add User
  </button>
</div>
Live demo
NameAge
Raheel Shan 40
Saleem Ahmad 21

Checkbox & Radio

Single checkboxes bind to booleans; multiple with same key bind to arrays. Radios bind to a string value.

 checkbox-radio.html
<!-- Checkbox group -->
<div data-bind="component:SkillPicker">
  <label>
    <input type="checkbox"
      data-bind="toggle:skills"
    /> All
  </label>
  <label>
    <input type="checkbox"
      value="HTML"
      data-bind="value:skills"
    /> HTML
  </label>
  <span data-bind="text:skills"></span>
</div>

<!-- Radio buttons -->
<div data-bind="component:GenderSelector">
  <label>
    <input type="radio"
      name="gender" value="male"
      data-bind="value:gender" checked
    /> Male
  </label>
</div>
Live demo
Checkbox group with Select All
Selected:
Radio buttons
Selected:

HTML Binding

Bind innerHTML from state. Edit the textarea to see the rendered output update in real time.

 html.html
<div data-bind="component:HTMLChecker">
  <textarea
    name="content"
    data-bind="value:details, update:input"
  >
    <b>Hello</b> <i>world!</i>
  </textarea>

  <div
    data-bind="html:details"
  ></div>
</div>
Live demo
Rendered output:

Dynamic Content / AJAX

jsRibbon's MutationObserver automatically registers components injected after page load — no manual rebind needed.

 dynamic-content.html
// Component registered before markup exists
jsRibbon.component('DynamicComp', ($state) => {
  const bump = () => $state.count++;
  return { bump };
});

// Inject HTML later (simulating AJAX response)
setTimeout(() => {
  container.innerHTML = `
    <div data-bind="component:DynamicComp">
      <span data-bind="text:count">0</span>
      <button data-bind="click:bump">+1</button>
    </div>
  `;
}, 1500);

<!-- No manual re-init required! -->
Live demo
⏳ Injecting component in 2s…