Zero-initial-JS selective activation engine. Turn server-rendered HTML into instant interactivity on intent.
CycleWire takes the architecture behind Qwik's loader and makes it usable with any backend or frontend. Your server renders complete HTML. CycleWire adds one small listener per event type it uses and imports the code behind a button only when someone reaches for it. It can also load that code when the element scrolls into view or when the browser is idle. Nothing is hydrated and nothing is re-rendered on boot, and there are no runtime dependencies.
- π Landing page and live demos: https://cyclechain.github.io/CycleWire/
- π§ͺ Live examples (React, Vue, Svelte, popular libraries): https://cyclechain.github.io/CycleWire/examples/
- π Documentation: docs/
- π’ Releases: https://github.com/CycleChain/CycleWire/releases
- Nothing to hydrate. The HTML your server sends is the UI. The 5.2 kB core (brotli) activates it; action code is fetched per feature, on demand.
- Intent-aware loading. Modules start downloading on hover, focus or touch, before
the click lands.
modulepreloadfetches them without running them, and Save-Data and 2G connections are respected. - Predictable under pressure. Every binding has a concurrency mode (
drop,restart,latest,parallel), anAbortSignal,onceanddebounce. Double submits and stale search results do not happen by accident. - Good for INP. Handlers run after the browser has painted the pressed state. Nothing heavy runs on load, and the page stays bfcache friendly.
- Works with anything. Laravel, Rails, Django, plain PHP, Astro, Web Components,
React/Vue/Svelte islands, htmx or Turbo. The attributes are short (
cw-action) and pass through JSX and every template language; their prefix is an option. - Modern platform features where they exist:
- Shadow DOM and declarative shadow DOM
- Invoker Commands
- View Transitions
moveBefore()scheduler.yield()- Trusted Types
- Speculation-rules prerendering
- Optional batteries, pay for what you import:
css: stylesheets that arrive with the actions that need themdom: safehtmltemplates, inert fragments, swapsmorph: DOM morphing that keeps state, instead of a virtual DOMsignals: reactivity that resumes from server-rendered statestream: HTML messages from the server that change the pageprefetch: data fetched on intent, next to the action's coderequest: links, forms and buttons that fetch HTML, declared in markup the way htmx does itearly: taps made before CycleWire starts, kept and run once it doesbootstrap: Bootstrap's data API without its JavaScript
1. Mark up what should be interactive. Your server renders this; it works and reads fine before any JavaScript arrives.
<button cw-action="cart#add" cw-props='{"sku": "wire-01"}'>Add to cart</button>2. Write the action. It is a plain ES module that is fetched on first use.
// actions/cart.js
export async function add({ element, props, signal }) {
const response = await fetch('/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(props),
signal,
});
element.textContent = response.ok ? 'Added β' : 'Try again';
}3. Register it and start, with a bundler:
import { start } from 'cyclewire';
start({
actions: {
cart: () => import('./actions/cart.js'),
},
});β¦or with no build step at all:
<script type="application/json" data-cyclewire>
{ "actions": { "cart": "/js/actions/cart.js" } }
</script>
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.global.min.js" defer></script>That's it. Until someone reaches for the button, the page has downloaded a single 5 kB script and no action code.
npm install cyclewirepnpm add cyclewireyarn add cyclewirebun add cyclewire| Entry | Import | For |
|---|---|---|
| Core | cyclewire |
Delegation, registry, triggers, preloading, concurrency, lifecycle events |
| Auto start | cyclewire/auto |
Reads <script type="application/json" data-cyclewire>, starts, sets window.CycleWire |
| DOM | cyclewire/dom |
html, fragment, swap, transition |
| Morph | cyclewire/morph |
morph |
| Signals | cyclewire/signals |
signal, computed, effect, store, stateOf, signals() plugin |
| Bootstrap | cyclewire/bootstrap |
Bootstrap 5 data API plugin |
Bundlers that honour the development export condition (Vite, webpack, Rollup) get a
build with helpful warnings in development and the lean one in production.
From a CDN. jsDelivr, unpkg and esm.sh serve every published version:
<!-- Classic script: core, starts itself, window.CycleWire -->
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.global.min.js" defer></script>
<!-- Classic script with every module (dom, morph, signals, bootstrap) -->
<script src="https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.full.global.min.js" defer></script>
<!-- ES modules -->
<script type="module">
import { start } from 'https://cdn.jsdelivr.net/npm/cyclewire@1/dist/cyclewire.min.js';
import { html, swap } from 'https://cdn.jsdelivr.net/npm/cyclewire@1/dist/dom.min.js';
</script>For production, pin an exact version (cyclewire@1.0.0) and add the
Subresource Integrity
hash published with each release.
Server renders HTML βββΊ browser paints it, fully usable as links and forms
β
CycleWire core, 5.2 kB: one listener per event type
β
pointer / focus ββββββΌβββββΊ preload: modulepreload the action (no execution)
β
click / submit / β¦ βββ΄βββββΊ resolve element β import action β yield β run
β²
cw-trigger: load Β· idle Β· visible Β· media:(β¦) ββ
Every interactive element carries its intent in markup (cw-action="cart#add").
One delegated listener per event type finds the element, looks the name up in the action
registry, imports that module the first time, and calls the handler with a context object.
This is Qwik's resumability without Qwik's compiler: the server is the source of truth,
and the client never rebuilds a component tree. Concepts walks
through it.
All attributes use the cw- prefix by default. start({ prefix: 'x-' }) moves
them to data-x-action and friends, and prefix: '' drops the prefix.
| Attribute | Purpose |
|---|---|
cw-action="name" |
Runs name on the element's natural event: submit for forms, input or change for controls, toggle for <details>, click otherwise |
cw-on-<event>="name" |
Runs name on any delegated event, e.g. cw-on-keydown, cw-on-command |
cw-trigger |
Runs without an event: load, idle, visible, media:(min-width: 60em) |
cw-preload |
When to fetch the module: intent (default), visible, idle, load, none |
cw-props='{β¦}' |
JSON handed to the handler as ctx.props |
cw-prevent |
preventDefault() for all bound events, or a list ("click keydown"); none disables the automatic prevent for forms and submit buttons |
cw-once |
Only one successful run |
cw-debounce="ms" |
Wait for a pause in events |
cw-concurrency |
drop (clicks, submits), restart (input), latest (change, toggle), parallel |
cw-ignore |
Stop looking for bindings above this element (use it on user-generated content) |
cw-pending |
Set by CycleWire while a run is in flight; style it |
Names are module or module#export. A module's run export, or its default export,
handles bare names. The full reference is in docs/html-api.md.
export async function run(ctx) {
ctx.event; // the triggering Event (null for triggers and run())
ctx.target; // the innermost event target
ctx.element; // the element that carries the binding
ctx.signal; // AbortSignal: a newer run, removal or stop() aborts it
ctx.props; // parsed cw-props
ctx.action; // "module#export"
ctx.wire; // the CycleWire API
}Return values are dispatched with cw:done, and errors with cw:error or your onError.
Because the handler runs after the event has been dispatched, calling
ctx.event.preventDefault() there has no effect; use cw-prevent instead. Details,
patterns and caveats are in docs/actions.md.
The core covers activation on its own: clicks, forms, inputs, triggers, preloading, concurrency and shadow DOM. Add a module when a feature needs it, ideally by importing it from the action that uses it, so it downloads with that action and pages that never use it never pay for it. docs/modules.md has core-only recipes, a decision table and every way to load a module.
import { css } from 'cyclewire/css';
await css('/css/vendor/flatpickr.css', element); // applied before the widget is built
import { html, swap } from 'cyclewire/dom';
swap(list, html`${items.map((item) => html`<li>${item.name}</li>`)}`); // escaped by context
import { morph } from 'cyclewire/morph';
const res = await fetch('/cart/partial');
await morph(cart, html.raw(await res.text()), { transition: true }); // keeps focus and state
import { start } from 'cyclewire';
import { signals } from 'cyclewire/signals';
start({ plugins: [signals()] }); // cw-state + cw-bind, resumed on first touch
import { bootstrap } from 'cyclewire/bootstrap';
start({ plugins: [bootstrap({ global: true })] }); // Bootstrap data API, no bootstrap.js
import { connect } from 'cyclewire/stream';
connect('/rooms/42/events'); // the server streams <cw-stream op="append" target="messages"> updates
import { prefetch } from 'cyclewire/prefetch';
start({ plugins: [prefetch()] }); // cw-prefetch data arrives with the code, not after it
start({ actions: { request: () => import('cyclewire/request') } });
// <button cw-action="request" cw-get="/cart" cw-target="#cart" cw-swap="morph">: HTML from the server, no JS of yours
import { earlyScript } from 'cyclewire/early'; // on the server, inline at the top of <head>:
const head = `<script>${earlyScript()}</script>`; // taps before start() are kept, then runThe CSS of visible content has to arrive before it paints, so it stays render-blocking on purpose. But a date picker, an editor or a map only exists after its action runs, so its stylesheet can wait for the action:
import { start } from 'cyclewire';
import { styles } from 'cyclewire/css';
start({
actions: {
datepicker: { module: '/js/actions/datepicker.js', css: '/css/vendor/flatpickr.css' },
},
plugins: [styles()],
});With the styles() plugin of cyclewire/css (0.6 kB), the stylesheet is preloaded with
the module on intent and applied before the handler runs, so it never blocks the first
render and the widget never appears unstyled. docs/css.md covers the rest
of the CSS strategy.
A virtual DOM re-renders the HTML the server already produced so it can diff it, and that is exactly the hydration cost this architecture exists to avoid. CycleWire gets the same benefits another way:
morph()compares DOM to DOM and keeps focus, typed input, iframes and media.htmlparses into an inert<template>and hands over aDocumentFragment, so markup is built safely and inserted in one operation.
docs/integrations.md has recipes for:
- Backends: Laravel/Blade, Rails, Django, plain PHP.
- Frameworks and build tools: Astro, Vite (
fromGlob(import.meta.glob(β¦))), webpack, React/Vue/Svelte islands. - Alongside other libraries: htmx, Turbo, Web Components.
React, Vue and Svelte: CycleWire can load your islands, hydrating a server-rendered component only when it scrolls into view, and components can use CycleWire actions. See docs/frameworks.md.
SweetAlert2, Flatpickr, DataTables, jQuery: import a library in the action that uses it, and it loads when it is needed: the date picker on the field's first focus, the table enhancements when the table scrolls into view. See docs/libraries.md.
Both have runnable examples, tested in Chromium, Firefox and WebKit and live on GitHub Pages.
npx cyclewire check # every cw-* value in your templates, against your actionscyclewire checkreads HTML, Blade, ERB, Django, Jinja, Twig, JSX, Vue, Svelte and Astro templates and reports actions that are not registered, exports that do not exist and values CycleWire does not understand, with suggestions (did you mean "cart#add"?) and annotations on GitHub pull requests. CLI docscyclewire/viteregisters an actions directory as chunks, re-registers an action when you edit it instead of reloading the page, and keeps the declarations of your action names up to date. Vite plugindefineAction<Props>()types a handler's props, and with the generated namesrun('cart#ad')is a compile error. TypeScriptcyclewire/devtoolsis an inspector inside the page: registered and loaded actions, every run and how it ended, the development build's trace, waiting triggers, and what any element is bound to. Alt+Shift+W, ordevtools: truein the Vite plugin. Devtools
The core needs an ES2020 browser: Chrome/Edge 86+, Firefox 78+ or Safari 14+. Newer platform features are detected at runtime and fall back gracefully. The test suite runs on current Chromium, Firefox and WebKit. docs/browser-support.md lists every feature and its fallback.
Minified, measured by npm run size and enforced in CI:
| File | brotli | gzip |
|---|---|---|
cyclewire.min.js (core) |
5.2 kB | 5.7 kB |
css.min.js |
0.6 kB | 0.7 kB |
dom.min.js |
2.2 kB | 2.4 kB |
morph.min.js (includes what it needs from dom) |
2.2 kB | 2.5 kB |
signals.min.js |
3.4 kB | 3.8 kB |
stream.min.js (includes dom and morph) |
3.8 kB | 4.2 kB |
prefetch.min.js (a plugin) |
0.5 kB | 0.6 kB |
request.min.js (an action; includes morph and what it needs from stream) |
3.7 kB | 4.1 kB |
early.min.js (inline, before the core) |
0.4 kB | 0.5 kB |
bootstrap.min.js |
2.1 kB | 2.4 kB |
devtools.min.js (development only) |
7.1 kB | 7.9 kB |
cyclewire.global.min.js (core + auto start) |
5.4 kB | 5.9 kB |
cyclewire.full.global.min.js (everything) |
16.2 kB | 17.8 kB |
One product page, built with each stack the way its documentation recommends and measured
the same way in Chromium on GitHub's runners:
charts and tables Β·
methodology Β· run it yourself. The benchmark is
maintained by the authors of CycleWire, which is one of the stacks measured, so every app,
the runner and the raw data are in bench/, and corrections from the other
projects are welcome. There is no overall score.
Medians, lower is better. Time to effect runs from the input to the frame that shows the result; "Early tap" is a tap on "Add to cart" in the first frame after first paint. The website charts each metric with its 95% confidence interval, and lists more metrics and how each app is built.
Mobile: slow 4G, 4Γ CPU slowdown, touch. 15 iterations on 2026-09-26, INTEL(R) XEON(R) PLATINUM 8573C (4 cores, GitHub's hosted runner), Chrome 153.0.8010.12.
| Stack | JavaScript | LCP | TBT | Add to cart | Category filter | Live search | Quick view | Newsletter | Early tap |
|---|---|---|---|---|---|---|---|---|---|
| Static HTML (control) | 0.0 kB | 1,584 ms | 0 ms | 1,365 ms | 760 ms | 790 ms | 777 ms | 1,379 ms | by a page load, 1,624 ms |
| Vanilla JS (control) | 1.2 kB | 1,596 ms | 0 ms | 700 ms | 116 ms | 12 ms | 696 ms | 699 ms | in the page, 834 ms |
| Alpine.js | 18.0 kB | 1,748 ms | 35 ms | 701 ms | 128 ms | 22 ms | 701 ms | 701 ms | by a page load, 1,752 ms |
| Angular | 87.5 kB | 1,788 ms | 116 ms | 707 ms | 135 ms | 22 ms | 719 ms | 715 ms | by a page load, 1,652 ms |
| Astro + Preact | 13.1 kB | 1,588 ms | 0 ms | 710 ms | 120 ms | 13 ms | 712 ms | 710 ms | by a page load, 1,675 ms |
| CycleWire | 12.3 kB | 1,652 ms | 0 ms | 701 ms | 120 ms | 12 ms | 611 ms | 704 ms | in the page, 853 ms |
| CycleWire, inline (variant) | 6.9 kB | 1,428 ms | 0 ms | 718 ms | 136 ms | 12 ms | 614 ms | 710 ms | in the page, 856 ms |
| CycleWire, intent only (variant) | 5.9 kB | 1,616 ms | 0 ms | 1,217 ms | 620 ms | 13 ms | 644 ms | 707 ms | in the page, 1,819 ms |
| CycleWire, requests from markup (variant) | 9.4 kB | 1,640 ms | 0 ms | 708 ms | 708 ms | 805 ms | 612 ms | 716 ms | in the page, 852 ms |
| Hotwire (Turbo + Stimulus) | 31.9 kB | 1,688 ms | 0 ms | 731 ms | 775 ms | 853 ms | 748 ms | 727 ms | by a page load, 1,626 ms |
| htmx | 15.4 kB | 1,560 ms | 0 ms | 713 ms | 751 ms | 838 ms | 717 ms | 712 ms | in the page, 866 ms |
| Marko | 4.8 kB | 1,620 ms | 0 ms | 704 ms | 118 ms | 12 ms | 698 ms | 701 ms | in the page, 834 ms |
| Next.js | 120.1 kB | 1,876 ms | 91 ms | 762 ms | 736 ms | 834 ms | 1,303 ms | 714 ms | by a page load, 993 ms |
| Next.js, client filtering (variant) | 119.2 kB | 1,828 ms | 90 ms | 753 ms | 142 ms | 21 ms | 1,316 ms | 717 ms | by a page load, 988 ms |
| Nuxt | 70.9 kB | 2,028 ms | 92 ms | 706 ms | 148 ms | 31 ms | 734 ms | 705 ms | by a page load, 1,703 ms |
| Qwik City | 37.4 kB | 1,728 ms | 0 ms | 802 ms | 184 ms | 31 ms | 773 ms | 804 ms | in the page, 2,466 ms |
| SolidStart | 31.1 kB | 1,580 ms | 0 ms | 709 ms | 127 ms | 14 ms | 719 ms | 707 ms | by a page load, 1,671 ms |
| SvelteKit | 32.8 kB | 1,824 ms | 0 ms | 1,299 ms | 143 ms | 18 ms | 635 ms | 1,300 ms | by a page load, 963 ms |
Where another stack beats CycleWire here (the 95% confidence intervals do not overlap and the difference is at least 3%): First Contentful Paint (Astro + Preact 1,204 ms, CycleWire 1,268 ms); Largest Contentful Paint (htmx 1,560 ms, CycleWire 1,652 ms); JavaScript (Marko 4.8 kB, CycleWire 12.3 kB); HTML (Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB); Requests (Alpine.js 24, CycleWire 28); Main thread (Marko 351 ms, CycleWire 365 ms); Script (Qwik City 15 ms, CycleWire 26 ms); Bytes, repeat visit (Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB); JS heap (Marko 1075.4 kB, CycleWire 1121.2 kB); Event listeners (Marko 9, CycleWire 11).
Desktop: fast connection, no CPU slowdown, mouse. 15 iterations on 2026-09-26, AMD EPYC 7763 64-Core Processor (4 cores, GitHub's hosted runner), Chrome 153.0.8010.12.
| Stack | JavaScript | LCP | TBT | Add to cart | Category filter | Live search | Quick view | Newsletter | Early tap |
|---|---|---|---|---|---|---|---|---|---|
| Static HTML (control) | 0.0 kB | 376 ms | 0 ms | 427 ms | 267 ms | 315 ms | 272 ms | 433 ms | by a page load, 509 ms |
| Vanilla JS (control) | 1.2 kB | 380 ms | 0 ms | 250 ms | 86 ms | 10 ms | 251 ms | 251 ms | in the page, 284 ms |
| Alpine.js | 17.9 kB | 376 ms | 0 ms | 250 ms | 100 ms | 28 ms | 251 ms | 251 ms | in the page, 289 ms |
| Angular | 87.5 kB | 388 ms | 0 ms | 250 ms | 101 ms | 23 ms | 266 ms | 251 ms | in the page, 299 ms |
| Astro + Preact | 13.1 kB | 388 ms | 0 ms | 251 ms | 87 ms | 11 ms | 251 ms | 251 ms | by a page load, 521 ms |
| CycleWire | 9.6 kB | 380 ms | 0 ms | 250 ms | 99 ms | 7 ms | 115 ms | 250 ms | in the page, 283 ms |
| CycleWire, inline (variant) | 4.1 kB | 404 ms | 0 ms | 266 ms | 116 ms | 7 ms | 116 ms | 251 ms | in the page, 299 ms |
| CycleWire, intent only (variant) | 5.9 kB | 380 ms | 0 ms | 249 ms | 98 ms | 11 ms | 115 ms | 250 ms | in the page, 382 ms |
| CycleWire, requests from markup (variant) | 9.2 kB | 384 ms | 0 ms | 250 ms | 250 ms | 364 ms | 116 ms | 250 ms | in the page, 284 ms |
| Hotwire (Turbo + Stimulus) | 31.9 kB | 380 ms | 0 ms | 265 ms | 182 ms | 385 ms | 215 ms | 266 ms | in the page, 300 ms |
| htmx | 15.3 kB | 428 ms | 0 ms | 250 ms | 255 ms | 370 ms | 251 ms | 251 ms | in the page, 285 ms |
| Marko | 4.8 kB | 384 ms | 0 ms | 250 ms | 87 ms | 10 ms | 251 ms | 251 ms | in the page, 284 ms |
| Next.js | 120.1 kB | 388 ms | 0 ms | 255 ms | 251 ms | 371 ms | 416 ms | 250 ms | in the page 13/15, by a page load 2/15, 450 ms |
| Next.js, client filtering (variant) | 119.2 kB | 388 ms | 0 ms | 254 ms | 100 ms | 7 ms | 416 ms | 250 ms | in the page, 367 ms |
| Nuxt | 70.9 kB | 384 ms | 0 ms | 250 ms | 117 ms | 27 ms | 252 ms | 251 ms | in the page, 284 ms |
| Qwik City | 37.4 kB | 392 ms | 0 ms | 284 ms | 104 ms | 28 ms | 267 ms | 284 ms | in the page, 500 ms |
| SolidStart | 31.1 kB | 392 ms | 0 ms | 250 ms | 100 ms | 12 ms | 99 ms | 250 ms | in the page, 284 ms |
| SvelteKit | 32.7 kB | 396 ms | 0 ms | 401 ms | 115 ms | 8 ms | 98 ms | 400 ms | in the page, 523 ms |
Where another stack beats CycleWire here (the 95% confidence intervals do not overlap and the difference is at least 3%): First Contentful Paint (Astro + Preact 348 ms, CycleWire 380 ms); JavaScript (Marko 4.8 kB, CycleWire 9.6 kB); HTML (Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB); Requests (Alpine.js 40, CycleWire 42); Layout (htmx 9.4 ms, CycleWire 10 ms); Quick view (SvelteKit 98 ms, CycleWire 115 ms); Bytes, repeat visit (Hotwire (Turbo + Stimulus) 2.7 kB, CycleWire 2.8 kB); Event listeners (Marko 9, CycleWire 11).
- Markup can only reach code through names you register. There is no
eval, nonew Function, and no URL is ever read from markup. htmlescapes by context, refuses positions escaping cannot protect, and rejectsjavascript:URLs however the attribute value is put together.- Nothing inside
cw-ignoreactivates: no actions, triggers, preloads or bindings. - Parsed fragments stay inert until they are inserted, and inserted
<script>elements never run. - The library works under strict CSP and Trusted Types.
See docs/security.md, including a DOMPurify recipe for user-generated content, and SECURITY.md for reporting.
- CycleWire follows Semantic Versioning. Every release is an
annotated git tag (
v1.0.0,v1.1.0, β¦) with a GitHub Release. Its notes come from CHANGELOG.md, and the built files and SRI hashes are attached. - Releases are published to npm from GitHub Actions with trusted publishing, so every version from 1.0.1 on carries a signed provenance attestation.
- On a CDN,
cyclewire@1follows the latest 1.x release, andcyclewire@1.0.0pins one.
The release process is documented in docs/releasing.md.
| Guide | |
|---|---|
| Getting started | Install, first action, first trigger |
| Concepts | The architecture, compared with Qwik |
| HTML API Β· JavaScript API | Complete reference |
| TypeScript Β· Command line Β· Vite plugin Β· Devtools | Typed actions, template checks, edits without reloads, an in-page inspector |
| Actions | Context, signals, concurrency, errors, patterns |
| css Β· dom Β· morph Β· signals Β· plugins | Optional modules |
| Shadow DOM | Web Components and declarative shadow DOM |
| Integrations | Backends, frameworks, bundlers |
| Frameworks Β· Libraries Β· Examples | React, Vue and Svelte islands; Flatpickr, SweetAlert2, DataTables, jQuery |
| Performance Β· Security Β· Browser support | |
| Core first, modules later Β· CSS strategy | Choosing what to load |
| Releasing | For maintainers |
Issues and pull requests are welcome. CONTRIBUTING.md covers setup, tests and the size budgets.
MIT Β© 2026 CycleChain