Vertex is a 1kloc SPA framework containing everything you need from React, Ractive-Load and jQuery while still being jQuery-compatible.
vertex.js is a single, self-contained file with no build step and no dependencies. Download it from the Gist below 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://gist.githubusercontent.com/LukeB42/ef5b142325fc2bcd4915ba9b452f6230/raw/867cf609e8ec6bcb6700eda7f81c7c60e9ee01c9/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));
Vertex's reconciler follows the same fiber architecture described at pomb.us. The public API is intentionally React-compatible.
const { createElement: h, render, Fragment } = Vertex; // Host element h("div", { className: "card" }, h("h2", null, "Hello"), h("p", null, "World") ) // Function component function Badge({ label, colour }) { return h("span", { style: { background: colour } }, label); } h(Badge, { label: "Bass", colour: "#c8ff00" }) // Fragment — renders children with no wrapper element h(Fragment, null, h("dt", null, "BPM"), h("dd", null, "174") )
// Mount your root component once — Vertex handles all subsequent updates function App() { return h("div", { className: "app" }, h("h1", null, "Vertex") ); } Vertex.render( h(App, null), document.getElementById("root") );
// Vertex.lazy() follows the React.lazy Suspense protocol. // The component is fetched once; Vertex re-renders automatically. const HeavyChart = Vertex.lazy(() => import("/static/js/chart.js")); function Dashboard() { return h(HeavyChart, { data: chartData }); }
All hooks follow React's rules: call them only at the top level of a function component, never inside conditionals or loops.
function Counter() { const [count, setCount] = Vertex.useState(0); return h("div", null, h("span", null, String(count)), h("button", { onClick: () => setCount(c => c + 1) }, "+"), h("button", { onClick: () => setCount(0) }, "reset") ); }
function reducer(state, action) { switch (action.type) { case "add": return { ...state, items: [...state.items, action.item] }; case "clear": return { ...state, items: [] }; default: return state; } } function Playlist() { const [state, dispatch] = Vertex.useReducer(reducer, { items: [] }); return h("ul", null, ...state.items.map(t => h("li", { key: t.id }, t.title)) ); }
function AudioPlayer({ src }) { const audioRef = Vertex.useRef(null); // Run once on mount — teardown on unmount Vertex.useEffect(function() { const ctx = new AudioContext(); audioRef.current = ctx; return () => ctx.close(); // cleanup }, []); // Re-run when src changes Vertex.useEffect(function() { if (audioRef.current) loadTrack(audioRef.current, src); }, [src]); return h("div", { className: "player" }, "Playing: " + src); }
function TrackList({ tracks, filter }) { // Only re-computed when tracks or filter changes const filtered = Vertex.useMemo( () => tracks.filter(t => t.genre === filter), [tracks, filter] ); // Stable function reference — safe to pass to child components const handleClick = Vertex.useCallback( t => console.log("selected:", t.title), [] ); return h("ul", null, ...filtered.map(t => h("li", { onClick: () => handleClick(t) }, t.title) ) ); }
function FocusInput() { const inputRef = Vertex.useRef(null); Vertex.useEffect(() => { if (inputRef.current) inputRef.current.focus(); }, []); return h("input", { ref: inputRef, // Vertex will write the DOM node here placeholder: "Type…" }); }
const ThemeCtx = Vertex.createContext("dark"); function App() { return h(ThemeCtx.Provider, { value: "dark" }, h(Toolbar, null) ); } function Toolbar() { const theme = Vertex.useContext(ThemeCtx); return h("nav", { className: "toolbar theme-" + theme }); }
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}}} |
Raw / unescaped HTML |
{{user.name}} |
Nested dot-path resolution |
{{#each items}} … {{/each}} |
Loop — keys from each item available directly; @index for position |
{{#if flag}} … {{/if}} |
Conditional block |
{{#if flag}} … {{else}} … {{/if}} |
Conditional with fallback |
Add data-bind="keypath" to any <input>
inside a template and Vertex will keep the input and the data
object in sync automatically:
new Vertex.template({ el: "#form", template: '<input data-bind="username" placeholder="Username">', data: { username: "" }, oncomplete() { console.log("mounted, username =", this.get("username")); } });
// 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)); });
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();
useHash() is a hook that returns the current
location.hash fragment and automatically re-renders
the component on every hashchange event. It's the
idiomatic way to drive the fiber reconciler from URL state.
const { createElement: h, render, useHash } = Vertex; const ROUTES = { "": Home, "projects": Projects, "about": About }; function App() { const hash = useHash(); // e.g. "#/projects" const key = hash.replace(/^#\//, ""); const Page = ROUTES[key] || NotFound; return h("main", null, h("nav", null, h("a", { href: "#/" }, "Home"), h("a", { href: "#/projects" }, "Projects"), h("a", { href: "#/about" }, "About") ), h(Page, null) ); } render(h(App, null), document.getElementById("root"));
Combine with Vertex.Router when you also need pattern
matching on dynamic segments — useHash handles the
re-render cycle while the Router extracts params.
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.createElement(type, props, …children) | Create a virtual element descriptor |
Vertex.render(element, container) | Mount or update the component tree |
Vertex.Fragment | Wrapper-free grouping element |
Vertex.lazy(factory) | Async component loader |
Vertex.createContext(default) | Create a context object |
Vertex.useState(initial) | Hook: local state |
Vertex.useReducer(reducer, initial) | Hook: reducer-based state |
Vertex.useEffect(fn, deps) | Hook: side effects & cleanup |
Vertex.useMemo(fn, deps) | Hook: memoised value |
Vertex.useCallback(fn, deps) | Hook: memoised callback |
Vertex.useRef(initial) | Hook: mutable ref |
Vertex.useContext(ctx) | Hook: read context value |
Vertex.useHash() | Hook: reactive URL hash |
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.Router | Singleton hash router |
Vertex.RouterClass | Backbone-style base class |
Vertex.$v(selector) | VQuery DOM wrapper |
Vertex.ajax(options) | Fetch wrapper |
Vertex.get(url, …) | Shorthand GET |
Vertex.post(url, …) | Shorthand POST |
| 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 |