Vertex is a 1kloc library combining a jQuery-compatible DOM layer, a full Mustache template engine (Ractive.load-style — fetch a template, bind data, mount it), and a Backbone-style hash router. It deliberately has no React-style component layer — no virtual DOM, no fiber reconciler, no hooks. Here's the template engine's two-way data binding, live — drag the slider, or pick a track:
That's genuine two-way binding — the data stays live via nothing but
the data-bind attribute itself, with a couple of lines of
plain JS painting the bar and percentage as you drag (§06 has the
full breakdown) — while the buttons show the same data object taking
an ordinary .set() call just as easily. vertex.js itself
is a single, self-contained file with no build step and no
dependencies. Grab it straight from this site and drop it wherever
your project serves static assets.
Or fetch it from the command line:
# save to your project's static directory
curl -o static/vertex.js https://float64co.github.io/vertex.js
It ships as a UMD module, so it works equally as a plain
<script> tag, a CommonJS require(),
or an AMD define().
Add a single <script> tag. Place it before any
code that references Vertex or V$.
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>My App</title> <script src="/static/vertex.js"></script> <!-- If you also use jQuery, load it BEFORE vertex.js. vertex.js detects $ and leaves it untouched. --> <!-- <script src="/static/jquery.min.js"></script> --> </head> <body> <div id="root"></div> </body> </html>
After loading, the following globals are available:
| Global | Description |
|---|---|
Vertex |
Full namespace — all features live here |
V$ |
Shorthand DOM wrapper — always available |
$ |
Also set to the DOM wrapper only when jQuery is absent |
Set Vertex.template.load.baseUri once at startup and every
subsequent load() call that receives a relative path will
automatically prepend it. Absolute URLs (starting with
http://, https://, or /) are
always used as-is, so fully-qualified paths continue to work unchanged.
// main.js — set the base once, then use short names everywhere Vertex.template.load.baseUri = "/static/templates/"; // "user-card" resolves to /static/templates/user-card Vertex.template.load("user-card", { el: "#sidebar", data: { name: "Alice", role: "Engineer" } }).then(instance => { instance.on("change", e => console.log("changed:", e)); }); // Absolute paths bypass baseUri entirely Vertex.template.load("/other/path/special.html", { el: "#special" }); Vertex.template.load("https://cdn.example.com/tmpl.html", { el: "#remote" });
A template file at /static/templates/user-card.html
is just a regular HTML fragment wrapped in a
<template> tag:
<!-- /static/templates/user-card.html --> <template> <div class="card"> <h2>{{name}}</h2> <p>{{role}}</p> {{#if email}}<a href="mailto:{{email}}">{{email}}</a>{{/if}} </div> </template>
<template>
tag, Vertex.template.load() uses the entire response body as
the template string. Both forms work.
V$(selector) returns a chainable wrapper around a set of
matched elements — identical in spirit to hn.js with a fuller jQuery
surface. Every method returns this for chaining.
// CSS selector V$(".card").addClass("active"); // HTML creation const el = V$('<li class="item">Hello</li>'); // Scoped query (2nd arg = context) V$("li", "#my-list").each(function() { console.log(this.textContent); }); // Document ready V$(function() { console.log("DOM ready"); });
// Direct event binding V$("button").on("click", function(e) { V$(this).toggleClass("pressed"); }); // Multiple events at once V$("input").on("focus blur", function() { V$(this).toggleClass("active"); }); // Event delegation (bubbles up from ".row" to "#table") V$("#table").on("click", ".row", function(e) { console.log("row clicked:", this.dataset.id); }); // Remove handler const handler = e => doSomething(e); V$("#btn").on("click", handler); V$("#btn").off("click", handler); // Custom event dispatch V$("#root").trigger("app:ready", { version: "1.0" });
// .attr(name) → get // .attr(name, val) → set (chainable) // .css(prop) → get computed value // .css(prop, val) → set style property // .css({ prop: val }) → set multiple // .val() → get input value // .val(v) → set input value V$("img") .attr("alt", "A scenic photo") .css({ borderRadius: "4px", opacity: "0.9" }); const username = V$("#name-input").val(); V$("#name-input").val("").attr("placeholder", "Enter name…");
// Content V$("#output").html("<strong>Done.</strong>"); V$("#label").text("Status: OK"); V$("ul").append("<li>New item</li>"); V$("ul").prepend("<li>First item</li>"); // Traversal V$(".panel").find("input").val(""); // clear all inputs inside .panel V$("li.active").parent().addClass("has-active"); V$("li").first().addClass("leader"); V$("li").eq(2).remove(); V$("li").filter(function(el, i) { return i % 2 === 0; }).addClass("even"); V$(".item").not(".disabled").on("click", handleClick);
Vertex.ajax() wraps the Fetch API with a jQuery-shaped
surface: success/error callbacks, dataType, content-type handling,
and .done()/.fail() on the returned Promise.
// Full options form Vertex.ajax({ url: "/api/tracks", method: "GET", data: { genre: "bass", limit: 20 }, dataType: "json", success: tracks => renderTracks(tracks), error: err => console.error(err) }); // POST with JSON body Vertex.ajax({ url: "/api/session", method: "POST", contentType: "application/json", data: { token: myToken }, success: session => startSession(session) }); // Promise style Vertex.ajax({ url: "/api/ping" }) .done(res => console.log("ok", res)) .fail(err => console.warn("failed", err)); // Shorthand GET / POST Vertex.get("/api/user", data => console.log(data)); Vertex.post("/api/save", { title: "Mix A" }, res => console.log(res));
The Vertex.template constructor takes an element target, a
mustache template string, and a data object. It renders immediately
and re-renders on every .set() or .update().
const r = new Vertex.template({ el: "#app", template: ` <h1>{{title}}</h1> <ul> {{#each tracks}} <li>{{@index}}. {{name}} — {{bpm}} BPM</li> {{/each}} </ul> `, data: { title: "My Set", tracks: [ { name: "Vortex", bpm: 174 }, { name: "Subsonic", bpm: 140 }, ] } }); // Update a single key — triggers re-render r.set("title", "Night Set"); // Merge multiple keys at once r.update({ title: "Morning Set", tracks: [] }); // Listen for data changes r.on("change", ({ keypath, value }) => { console.log(keypath, "→", value); });
| Syntax | Behaviour |
|---|---|
{{key}} |
HTML-escaped interpolation |
{{{key}}} / {{&key}} |
Raw / unescaped HTML (two equivalent forms) |
{{user.name}} |
Nested dot-path resolution |
{{#each items}} … {{/each}} |
Explicit, array-only loop — a silent no-op on anything that isn't an array. Item keys and @index available directly inside |
{{#name}} … {{/name}} |
Full Mustache section: an array loops (like #each); a truthy object or scalar renders once with its keys pushed into scope; falsy or an empty array renders nothing |
{{^name}} … {{/name}} |
Inverted section — renders only when name is falsy or an empty array |
{{#if flag}} … {{/if}} |
Conditional block |
{{#if flag}} … {{else}} … {{/if}} |
Conditional with fallback |
{{! comment }} |
Comment — produces no output |
{{> name}} |
Partial — resolved against the partials map passed to the template (see below) |
Sections nest to any depth. Closing tags are matched structurally
(a stack), not by name — {{#foo}} … {{/bar}}
closes the same way {{#foo}} … {{/foo}} would.
new Vertex.template({ el: "#app", template: '<ul>{{#each users}}{{> user-row}}{{/each}}</ul>', partials: { "user-row": '<li>{{name}} — {{role}}</li>' }, data: { users: [{ name: "Alice", role: "Engineer" }] } });
Pass a computed map of getter functions alongside
data. Each key is defined on the data object with
this bound to the template instance, so it can read
other fields and is re-evaluated on every render:
const list = new Vertex.template({ el: "#results", template: '{{#each visible}}<li>{{label}}</li>{{/each}}', data: { items: [...], filter: "" }, computed: { visible: function () { return this._data.items.filter(function (i) { return i.label.includes(this._data.filter); }, this); } } }); list.set("filter", "bass"); // re-renders "visible" automatically
That live demo at the top of the page is this in its entirety: add
data-bind="keypath" to any <input>
inside a template and Vertex keeps the input and the data object in
sync automatically — no manual event wiring on your end. The range
input has to live inside the template's own markup for this to
work; data-bind only wires up inputs the template itself
rendered, not ones sitting elsewhere on the page.
const TRACKS = [ { name: "Jungle Pressure", bpm: 170 }, { name: "Dark Matter DnB", bpm: 174 }, { name: "Halftime Drift", bpm: 85 } ]; const demo = new Vertex.template({ el: "#md-out", template: ` <input type="range" min="0" max="100" data-bind="progress"> <span class="pct">{{progress}}%</span> {{#each tracks}} <div class="track-row{{#if active}} active-row{{/if}}"> {{@index}}. {{name}} — {{bpm}} bpm </div> {{/each}} <span class="bar" style="width:{{progress}}%"></span> `, data: { progress: 0, tracks: TRACKS.map((t, i) => ({ ...t, active: i === 0 })) } }); // data-bind="progress" keeps the data live and commits once the drag // settles — but that commit is debounced (see below), so the bar and // percentage wouldn't visibly move DURING the drag on their own. This // paints them on every tick via a direct write to two plain elements // that have no native interactive state — never the slider itself. Vertex.$v("#md-out").on("input", "input[type=range]", function () { Vertex.$v("#md-out .pct").text(this.value + "%"); Vertex.$v("#md-out .bar").css("width", this.value + "%"); }); // only the button-driven "active track" needs an explicit .set() Vertex.$v("#md-tracks").on("click", "button", function () { const idx = parseInt(this.dataset.track, 10); demo.set("tracks", TRACKS.map((t, i) => ({ ...t, active: i === idx }))); });
Every change still replaces the template's entire innerHTML
— there's no virtual DOM here — which raises an obvious question for a
range input specifically: a slider's drag is native
pointer-capture state tied to that exact DOM node, and per spec that
capture releases the instant the element leaves the document, even if
an identical node is reinserted the same tick. Re-rendering on every
input tick would silently end the drag after the first
pixel of movement. So for <input type="range">
specifically, input events update the data model directly
— .get() is always live — without touching the DOM at
all; the official re-render (the one the template engine itself
drives, reconciling everything bound to that key) fires once,
debounced ~120ms after the last input event goes quiet.
That's deliberately not wired to the native change event
— some browsers fire it more than once per drag instead of exactly
once at release, which would mean occasionally re-rendering (and
interrupting) mid-gesture anyway. Watching for a gap in
input events needs no assumption about any browser's
change semantics at all.
That debounce is why the demo's bar and percentage visibly move
during the drag despite all that — they're not waiting on the
template at all. A second, plain input listener (see the
code above) writes their width/text directly on every tick, entirely
outside the render pipeline. That's safe specifically because a
<span> has no native interactive state to lose —
unlike the slider, it was never risky to touch mid-drag, just
pointless to route through a whole-subtree re-render for. The official,
debounced re-render still happens once the drag settles, so anything
else bound to progress — with no hand-rolled
listener of its own — still ends up correct, just a beat later.
Every other input type still re-renders on every keystroke, with the currently-focused element spliced back in as the same live node rather than a freshly-parsed lookalike, so cursor position survives too. See the interop guide for what "everything else gets fully rebuilt on every change" means for foreign DOM you park inside a template yourself.
// Set base once at startup Vertex.template.load.baseUri = "/static/templates/"; // Short name resolves to /static/templates/player.html Vertex.template.load("player", { el: "#player-container", data: { track: "Vortex.wav", playing: false } }).then(instance => { instance.on("change", e => syncBackend(e)); });
Mounting anything else — a third-party widget, a nested template — inside a template's own element? Its re-render will destroy it. Read the interop guide for the safe patterns.
Vertex.Router is a singleton. Routes are matched against
the URL fragment (#/…) using named parameters
(:name) and splats (*rest).
const { Router } = Vertex; Router .add("", params => showHome()) .add("projects", params => showProjects()) .add("projects/:id", params => showProject(params.id)) .add("files/*path", params => showFile(params.path)) .start(); // begins listening; dispatches current fragment // Navigate programmatically Router.navigate("projects/42"); // sets #/projects/42 Router.navigate("projects/42", { trigger: true }); // + fire handler // Remove a route Router.remove("files/*path"); // Stop / reset Router.stop(); Router.reset();
const AppRouter = Vertex.RouterClass.extend({ routes: { "": "home", "projects": "projects", "projects/:id": "project", "files/*path": "file" }, home() { console.log("home"); }, projects() { console.log("projects"); }, project({ id }) { console.log("project", id); }, file({ path }) { console.log("file", path); } }); const router = new AppRouter(); Vertex.Router.start();
Vertex is explicitly designed to coexist with jQuery on the same page. The rule is simple: load jQuery first, then vertex.js.
<!-- jQuery loaded first --> <script src="/static/jquery.min.js"></script> <script src="/static/vertex.js"></script>
vertex.js checks window.jQuery and window.$
before assigning anything. If they exist it leaves them completely
alone. Use V$ or Vertex.$v() for the
Vertex DOM wrapper in that scenario:
// jQuery and Vertex DOM layer side by side — no conflict $("#jq-widget").datepicker(); // ← jQuery V$("#vx-card").on("click", fn); // ← Vertex // Or explicitly via the namespace Vertex.$v("#vx-card").css("color", "#c8ff00");
jQuery static utilities are mirrored on Vertex.VQuery:
VQuery.extend(), VQuery.each(),
VQuery.isArray(), VQuery.isFunction(),
VQuery.trim(), VQuery.noop(),
VQuery.parseJSON(), and VQuery.now().
| Symbol | Description |
|---|---|
Vertex.template | Mustache template constructor |
Vertex.template.load(url, options) | Fetch and mount a remote template file |
Vertex.template.load.baseUri | Base path prepended to relative URLs (default "") |
Vertex.parseTemplate(tmpl, data, partials?) | Render a template string to HTML without mounting it |
Vertex.Router | Singleton hash router |
Vertex.RouterClass | Backbone-style base class |
Vertex.VQuery | The DOM wrapper constructor |
Vertex.$v(selector) | VQuery DOM wrapper |
Vertex.ajax(options) | Fetch wrapper |
Vertex.get(url, …) | Shorthand GET |
Vertex.post(url, …) | Shorthand POST |
| Method | Description |
|---|---|
.get(keypath) | Read a value from the data object (dot-path supported) |
.set(keypath, value) | Write one value and re-render; emits "change" |
.update(partialData) | Merge multiple keys into the data object and re-render |
.on(event, fn) / .off(event, [fn]) | Listen for/remove "change" handlers |
.teardown() | Clear the mounted element and drop all handlers |
| Method | Description |
|---|---|
.on(events, [sel], fn) | Bind event (delegation if sel given) |
.off(events, [fn]) | Remove event handler(s) |
.trigger(event, [detail]) | Dispatch CustomEvent |
.attr(name, [val]) | Get / set attribute |
.css(prop, [val]) | Get computed / set inline style |
.val([v]) | Get / set input value |
.html([content]) | Get / set innerHTML |
.text([content]) | Get / set textContent |
.addClass / .removeClass / .toggleClass / .hasClass | Class manipulation |
.append / .prepend / .after / .before | DOM insertion |
.find / .parent / .children / .closest / .siblings | Traversal |
.first / .last / .eq(i) / .get(i) | Subset selection |
.filter / .not / .is / .add | Filtering |
.remove / .empty / .clone | DOM mutation |
.each(fn) | Iterate matched elements |
.data(key, [val]) | Get / set data-* attribute |
.hide / .show / .toggle | Visibility shortcuts |
.width / .height / .offset | Dimension helpers |
.serialize() | Serialise form to query string |
.prop(name, [val]) | Get / set DOM property |