The component is the file. Save counter.html and the browser runs it
byte-for-byte — reactive, scoped, untouched.
<!-- counter.html -->
<h2>Count: {count}</h2>
<button onclick={inc}>+1</button>
<script>
import { fmt } from './format.js'; // standard JS imports work too
let count = 0;
function inc() { count++; }
</script>
No compiler generates code from your template. No virtual DOM allocates and diffs a tree per frame. The file you write is what runs — 13 kB gzipped, zero dependencies.
Quick start
npx create-spark-html-app myapp
cd myapp && npm install && npm run dev
…or add it to an existing project:
// vite.config.js
import spark from 'spark-html/vite';
export default { plugins: [spark()] };
// main.js
import { mount } from 'spark-html';
mount();
<!-- index.html -->
<div import="components/counter"></div>
…or no build at all — straight from a CDN, no npm, no bundler:
<script type="importmap">
{ "imports": { "spark-html": "https://esm.sh/spark-html@0.26" } }
</script>
<div import="components/counter"></div>
<script type="module">import { mount } from 'spark-html'; mount()</script>
Serve any static folder and open it — that's the whole toolchain. Components are
just files at a URL, so you can even import one straight from a CDN. See
examples/no-build.
Performance
Components ship as authored HTML — no compiler generates code from your template. The file you write is what runs.
No virtual DOM — patches mutate the DOM directly. No intermediate tree to allocate, diff, or discard per frame.
13 kB gzipped, zero dependencies — parses, mounts, and patches in a single microtask.
O(changed) dependency tracking — each binding records which scope keys it reads. A write re-evaluates only the bindings that actually changed.
<p>{a} + {b} = {a + b}</p> <p>{c}</p> <script>let a = 1, b = 2, c = 3;</script>Updating
are-evaluates{a}and{a + b}. The{c}binding is skipped.Row-level loop patching — mutating one item re-walks only that row:
<template each="todo in todos" key="todo.id"> <p>{todo.text} — {todo.done ? '✓' : '○'}</p> </template> <script>let todos = [{ id: 1, text: 'a', done: false }, /* …999 more… */];</script>todos[3].done = truere-walks only row index 3 — the other 999 rows are untouched. A structural change (push, splice, re-sort) still re-reconciles but skips rows whose identity (key) didn't move.Tracked
Map/Setmutations —map.set(key, val),set.add(item), anddelete/cleartrigger re-renders, just like array push and object property assignment. No special API or immutability discipline required.
Limits
- One reactive scope per component — all top-level
let/functiondeclarations share a single proxy scope within each component. let/constinside functions — plain declarations (let x = 1) still hoist to component scope. Destructuring (let {a} = obj) stays block-local.- Class instances /
Date— not deeply reactive (intentional). Reassign the variable to trigger an update. Plain objects, arrays,Map, andSetare all tracked. - Loops reconcile by index by default — add
key="…"for identity-stable reordering (keeps focus, preserves element state). - CSP — the runtime uses
new Functionfor expressions and event handlers, so a strict Content Security Policy needsunsafe-eval. import.meta— not available inside component scripts (imports are replayed as dynamicimport()). Bare specifiers need an import map when running without a bundler.
How it works
mount()finds<div import="…">placeholders and fetches each file.- Text-level extraction —
<script>and<style>are extracted from the raw text before the markup ever touchesinnerHTML. Browsers strip<script>tags injected viainnerHTML; text-level extraction sidesteps the entire class of bugs that every other client-only framework has to work around. - The script runs inside a
Proxyscope — every assignment schedules a patch of only that component's DOM. Patches are batched onto a single microtask. - Cheap patches — static subtrees (no bindings) are walked once and then skipped. A patch costs work proportional to dynamic nodes, not the whole tree.
- Deep reactivity — plain objects and arrays read from scope are wrapped in
proxies so
todos.push(x)androw.done = truere-render without replacing the value.MapandSetmutations are tracked too. - Styles are auto-scoped via a
[name="component"]prefix.@media/@supportsscope correctly,@keyframes/@font-facepass through,:global(…)opts out. - Loops reconcile by key — each item keeps its DOM nodes across updates
(matched by index, or by
key), so inputs inside loops keep focus. - A cloak style hides components via
visibility:hiddenuntil booted and patched — no flash of raw{braces}or unstyled markup.
Packages
Runtime
| Package | What it does |
|---|---|
spark-html |
The runtime — mount(), components, reactivity, store/derived, bind:form, scoped styles. 13 kB gzip, 0 deps. |
Optional sibling packages (add only what you use)
| Package | What it does |
|---|---|
spark-html-router |
Declarative routing — <template route> + router(), active links, a reactive route store. |
spark-html-theme |
One-line dark/light/system theming — theme(), persisted, no flash. |
spark-html-head |
Reactive document <title>/<meta> per route — one line, 0 deps. |
spark-html-motion |
Declarative enter/leave transitions — transition="fade|slide|scale" on if/each blocks. |
spark-html-query |
Declarative async data — a self-fetching reactive store (loading/error/data/refetch). |
spark-html-persist |
Persist a store across reloads in one line — hydrate from localStorage, save on change. |
spark-html-devtools |
In-page devtools panel — live store state, component tree, patch counter, re-render flash. |
Build & tooling
| Package | What it does |
|---|---|
spark-prerender |
Build-time SEO prerender — real HTML per route, no SSR server, no app changes. |
prettier-plugin-spark |
Prettier plugin — formats the <script>/<style> blocks, leaves markup byte-for-byte. |
create-spark-html-app |
Scaffold a Vite + spark-html app — npm create spark-html-app. |
This repo
packages/ spark-html + the sibling/tooling packages (& create-spark-html-app)
examples/basic a minimal Vite app consuming spark-html
website/ the showcase + docs site — built with Spark, the router & theme
npm install # links workspaces
npm run dev # the example app
npm run site # the website
npm test # 195+ assertions, pure node, no browser
Built something with Spark? Add it to the showcase — open a PR.
License
MIT