A tiny HTML‑first micro‑runtime that turns static pages into interactive UI — straight from a CDN.
- Start in seconds. Drop a script, go interactive.
- HTML‑first. Author behavior where you render.
- Zero build. No VDOM. Works on static hosting.
- Tiny API. Directives, inline expressions, class components.
<script type="module" src="https://cdn.jsdelivr.net/gh/softkittens/impetus@main/app/impetus.js" defer init></script>
<div scope='{"count":0}'>
<button onclick="count++">Clicked {count} times</button>
<!-- No build step. Works on any static page. -->
</div>- Example
- Key Benefits
- Quick Syntax
- Why Impetus
- Quick Start
- Components API (use + template)
- FAQ
- Acknowledgements
- Contributing
- License
<div scope='{"count": 0, "name": "Jane", "open": true}'>
Hello, {name}! Count is {count}.
<!-- Simple expressions -->
<button onclick="count++">+</button>
<button onclick="count=Math.max(0,count-1)">-</button>
<!-- Two-way binding -->
<input :value="name" />
<!-- Conditional display -->
<div @show="open" @transition="fade:150">Showing when open is true</div>
<button onclick="open=!open">Toggle</button>
<!-- Lists -->
<ul>
<template @each="[1,2,3] as n,i">
<li>Item {i}: {n}</li>
</template>
</ul>
</div><!-- Inline component uses its own content as the template -->
<div use="Counter">
<p>Count: {count}</p>
<button onclick="inc()">+</button>
<button onclick="dec()">-</button>
<button onclick="count = 0">reset</button>
</div>
<script>
class Counter {
count = 0
inc() { this.count++ }
dec() { this.count-- }
}
</script>SFCs allow defining components in external HTML files without global pollution.
Create components/counter.html:
<component name="simple-counter">
<div>{value}</div>
<button onclick="inc()">+</button>
<button onclick="dec()">-</button>
<script>
// Registers under 'SimpleCounter' automatically (no globals needed)
scope(class {
constructor({ value = 0 }) { this.value = Number(value) }
inc() { this.value++ }
dec() { this.value-- }
})
</script>
<style>
::host { background: lime; }
</style>
</component><!-- External SFC (loads from file) -->
<simple-counter src="./components/counter.html"></simple-counter>
<!-- Inline SFC (defined directly in page, no src needed) -->
<simple-counter></simple-counter>
<!-- Define inline SFC component -->
<component name="simple-counter">
<div>{value}</div>
<button onclick="inc()">+</button>
<script>
scope(class {
constructor({ value = 0 }) { this.value = Number(value) }
inc() { this.value++ }
})
</script>
</component>Notes: External SFCs load from files and cache fetches by URL. Inline SFCs are defined directly in the page using <component> tags.
- ⚡️ Fast to adopt — One script tag; works on any static page.
- 🧠 Simple mental model — Plain objects for state; plain HTML for views.
- 🧩 Real components when you need them — Class‑based components with templates and lifecycle.
- 🎯 Precise bindings —
{expr}in text/attrs;@if/@show/@eachfor structure;$eventfor robust events. - 🧪 Confidence — Tested core; examples in
app/. - 🌐 CDN‑first — Import from a CDN; no bundler required.
State (scoped root)
<div scope='{"name":"Jane","count":0}'>Hello, {name} ({count})</div>Text + attributes
<h1 class="{count>10 ? 'big' : 'small'}">Hi, {name}</h1>
<div style="{{ color: count>0 ? 'green' : 'red' }}"></div>Events and $event
<input :value="name" />
<button onclick="count++">Clicked {count}</button>
<div
onclick="$event.outside && (open=false)"
onfocusout="$event.outside && (open=false)"
onkeydown="$event.escape.prevent.stop && (open=false)"></div>Conditional display
<div @if="ok">Shown when ok</div>
<div @else>Fallback</div>
<div @show="loading" @transition="fade:200">Loading…</div>Lists
<ul>
<template @each="items as it,i">
<li key="{it.id}">{i}. {it.label}</li>
</template>
<!-- each clone gets { it, i, $root } -->
<!-- key preserves instances across reorders -->
</ul>Two-way model (shorthand)
<input :value="user.email" />- Zero‑setup progressive enhancement for static sites.
- HTML you already write, just reactive.
- Class components without a framework tax: constructor props, lifecycle, reactive templates.
- Microtask‑batched updates and per‑root caching keep it fast by default.
- You want to progressively enhance static pages without a build step.
- You prefer HTML-first templates and inline expressions over heavy frameworks.
- You need small, interactive widgets (forms, tabs, lists, modals) fast.
- You value simple reactivity and minimal API surface.
- You need a full SPA router, SSR/SSG integration, or complex app state patterns out of the box.
- You require a large ecosystem of plugins/components and tight framework tooling.
- You’re building a large-scale SPA where a full framework provides clear advantages.
Add a single script tag (auto-init):
<script type="module" src="https://cdn.jsdelivr.net/gh/softkittens/impetus@main/app/impetus.js" defer init></script>Or import explicitly:
<script type="module">
import { init } from 'https://cdn.jsdelivr.net/gh/softkittens/impetus@main/app/impetus.js'
init()
// or scope-only: init('[scope]')
</script><div scope='{"count": 0, "name": "Jane", "open": true}'>
Hello, {name}! Count is {count}.
<button onclick="count++">+</button>
<button onclick="count=Math.max(0,count-1)">-</button>
<input :value="name" />
<div @show="open" @transition="fade:150">Showing when open is true</div>
<button onclick="open=!open">Toggle</button>
<ul>
<template @each="[1,2,3] as n,i">
<li>Item {i}: {n}</li>
</template>
</ul>
</div>- Outside click + Escape + focus-out: use
$event.outsideonclickandfocusout, and$event.escape.prevent.stoponkeydown. - Two‑way input:
:value="path"(events wired automatically). - Conditional blocks:
@if/@elsefor DOM add/remove;@showfor toggling visibility. - Computed bits: rely on expression caching within a render pass.
State mounting (scoped root)
<div scope='{"name":"Jane"}'>
Hello, {name}
</div>Text interpolation
- Inline expressions render results in text and attributes.
- Escape braces with double braces:
{{and}}.
Attribute bindings (selected cases)
class="{ condition ? 'a' : 'b' }"supports inline expressions within braces.style="{ { color: ok ? 'green' : 'red' } }"supports object-to-inline css.value/checked/disabledare boolean-aware;:valueis two-way shorthand.
Event modifiers
- Use on any
on*attribute via dot modifiers:.prevent→onclick.prevent="submit()".stop→onclick.stop="inner()".once→onclick.once="helloOnce()"- Modifiers can be combined, e.g.
onclick.prevent.stop="..."
Events
- Inline:
onclick="count++",oninput="name=$event.target.value". $eventhelpers:$event.outsidefor boundary checks on the element the handler is bound to.- Works for
clickand focus transitions. Forfocusout, it usesrelatedTargetand treatsnullas outside.
- Works for
- Key alias helpers:
$event.escape,$event.enter,$event.space,$event.tab,$event.backspace.- Chain
.prevent.stopand use with&&in expressions, e.g.$event.escape.prevent.stop && close().
- Chain
keydownlisteners are attached ondocumentfor consistent keyboard behavior; outside listeners also usedocumentwhen needed.
Directives
@if="expr"/@else– conditional insert/remove (paired siblings).@show="expr"– toggles visibility viahidden/style.displaywithout DOM churn.@each="list as item,i"– clones a template element for each item.- Each clone gets an extended scope:
{ itemKey, idxKey, $root }. - Optional keyed mode: add
key="<expr>"on the holder element to preserve instances.- Example:
<li @each="items as it,i" key="it.id">.
- Example:
- Each clone gets an extended scope:
Transitions
- Apply to
@showblocks via@transition(currentlyfade[:durationMs]).- Example:
<div @show="open" @transition="fade:200">...</div>. - Concurrency-safe: rapid toggles won’t leave elements hidden incorrectly; transitions are versioned per element.
- Example:
Two-way model (shorthand)
:value="path"marks element as model-bound; runtime wires appropriate events and assigns back into scope.
Components use their inline HTML content as the template. Define a class and use use="ClassName" to instantiate it.
<div use="Counter" class="p-4 border rounded">
<h3>Inline Counter</h3>
<div>Count: {count}</div>
<button onclick="inc()">+</button>
<button onclick="dec()">-</button>
</div>
<script>
class Counter {
count = 0
inc() { this.count++ }
dec() { this.count-- }
}
</script>- Props come from
props='{...}'JSON plus any other attributes (coerced to booleans/numbers when possible) data-*andaria-*attributes keep their original names (e.g.,data-test→data-test)- Other attributes are converted to camelCase (e.g.,
max-items→maxItems) - If
inheritattribute is present, the component uses the nearest parent scope instance instead of constructing a new one
- Every mounted root (and component host) is wrapped by a Proxy via
makeReactive. - Any property writes schedule a microtask render of that root.
- Nested objects are wrapped lazily (on access).
- Multiple roots bound to the same proxy are tracked and re-rendered.
- When a reactive proxy is accessed from another root (e.g.,
$rootinside@eachclones), that root is registered as a dependent and will re-render on changes to the shared proxy.
Computed caching
- Expression functions are compiled once and cached globally for reuse.
- Reactive effects drive updates when state changes; no explicit computed cache.
Global store
- A shared reactive object exposed as
$storein every scope and component instance. - Read/write from templates
{ $store.count }or methodsthis.$store.count++. - Useful for cross-component state without a framework-level store.
- Expression compile/eval with caching and
with(state)scoping. - DOM helpers: boolean props, class/style normalization.
- Directive handlers:
@if/@else,@show,@eachwith ordered insertion. - Binding collection: attribute and text interpolation per-root.
- Event wiring: inline
on*converted to listeners;$eventproxy; outside-click helper. - Components:
use, props parsing, lifecycle hooks (onMount,onDestroy).
- Renders are microtask-batched per root (WeakSet guard).
@eachrespects array identity; preserves order via moving anchor.- Prefer
@showfor transient states (loading) to avoid DOM churn. - Use computed-like getters for filtered views; Impetus caches expr results per render.
- Production builds use
--define DEVTOOLS=falseand--drop console --drop debuggerto reduce size.
/counter.html– basic component API, props./sfc-counter.html– Single File Components with external HTML./inline-components.html– inline templates, template props, reusable components./list.html– search + filter,@if/@else,@each./tabs.html– accessible tabs: keyboard (arrow/home/end),@each, ARIA./todo.html– add/toggle/delete, filters, computed counts./fetch.html– async fetch, loading/error/show/empty./modal.html– outside/Escape close./tooltip.html– Tippy/Popper integration./select2.html– Select2 single/multi integration./store.html– Global$store, event modifiers,.once.
-
How to initialize automatically? Use
<script type="module" src="./impetus.js" defer init></script>. -
When to use
@showvs@if?@showtoggles visibility without DOM changes;@ifmounts/unmounts. -
How do I register components without globals?
Use SFCs withscope(class { ... })inside the component script. The loader registers under the derived class name (e.g.,simple-counter→SimpleCounter). -
When should I use SFCs vs. inline components?
SFCs for reusable, external components (no global pollution). Inline for simple, page-specific components. -
How does component resolution work?
use="ClassName"resolves in order:window.ClassName(global), SFC registration viascope(...)(derived from SFCname), script scanning (finds classes in<script>tags). -
How does
$event.outsidework?
The$eventproxy exposesoutsidewhich istrueif the click target is outside the bound element.
For CDN deployment: bun build src/index.ts --outfile app/impetus.js --target browser --format esm --minify --define DEVTOOLS=false --drop console --drop debugger
This project was created with Bun v1.3+. See app/ for examples and src/runtime.ts for the core runtime.
Impetus draws inspiration from the simplicity and ergonomics of projects like petite-vue and Alpine.
Issues are welcome. At this time, pull requests are not being encouraged while the API stabilizes.
If you want to run the project locally for experimentation:
bun install
# Build browser bundle and serve examples
bun run build:browser
bun run serve
# Dev (Bun server + hot) for Node-side index.ts
bun run dev
# Watch mode with optional devtools
bun run build:watch
# or enable devtools
bun run build:watch --devtools
# or via env var
WATCH_DEVTOOLS=1 bun run build:watch
# Type-check
bun run typecheckDevtools are conditionally loaded at build-time and are not included unless enabled.
MIT License. See LICENSE file.