Spark documentation

Everything you need to build with Spark — single-file HTML components with reactivity, props, and shared stores. No compiler, no virtual DOM.

The concept

Most modern frameworks place a toolchain between you and the browser: code is compiled from an invented dialect, UI updates flow through a virtual representation of the DOM, and shipping requires a build pipeline. Each layer adds power — and adds something to install, learn, debug, and keep upgraded.

Spark inverts the trade. A component is a plain HTML file the browser receives unmodified. State is ordinary variables; reactivity comes from intercepting assignments, not from re-rendering and diffing a copy of your UI. The runtime patches the real DOM directly and is small enough to read in one sitting. The result: no build step to maintain, nothing proprietary in your source, a mental model that is just HTML plus JavaScript, and components that stay readable in view-source forever.

This is a deliberate scope, not a missing roadmap — Spark targets the very large class of sites and apps that need interactivity, components, and shared state without a compiler's ceiling of complexity.

Installation

Scaffold a complete, ready-to-run app (Vite + the Spark plugin + a live welcome screen) in one command:

npx create-spark-html-app yourapp

Then cd yourapp && npm install && npm run dev. Or add Spark to an existing project:

npm install spark-html

Spark is a runtime library plus an optional Vite plugin. There is no compile step — component files are fetched and booted in the browser.

No build, no install

Because components are fetched at runtime and the runtime is a single ES module, you can use Spark with zero tooling — straight from a CDN with an import map. No npm, no bundler, no compiler:

<script type="importmap">
  { "imports": { "spark-html": "https://esm.sh/spark-html@0.22" } }
</script>

<div import="components/counter"></div>

<script type="module">
  import { mount } from 'spark-html';
  mount();
</script>

Serve the folder with any static server (npx serve, python3 -m http.server) 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 or another repo:

<div import="https://cdn.jsdelivr.net/gh/you/repo/card.html"></div>

This is Spark's sharpest edge: the file you ship is the file that runs, and "install" can be a single <script> tag. A complete runnable example lives in examples/no-build, and the home page's URL import tab fetches a component from a CDN live.

Components

A component is a plain .html file containing markup, an optional <script>, and an optional <style>:

<!-- components/welcome.html -->
<h1>Welcome {name}</h1>

<script>
  let name = 'John Doe';
</script>

<style>
  h1 { color: rebeccapurple; }
</style>

Use it anywhere with an import placeholder. The path resolves relative to the served root; the .html extension is optional:

<div import="components/welcome"></div>

Every top-level let, const, var, and function in the script becomes reactive component state. Assigning to a variable re-patches the component's DOM — that's the whole reactivity model.

Styles are automatically scoped to the component by a small CSS parser: selectors inside @media and @supports are scoped too (so responsive overrides just work), while @keyframes and @font-face are left untouched. Use :global(selector) to opt out for resets and page-level rules — it works anywhere in a selector, e.g. :global(.theme-dark) .card.

Template syntax

FeatureSyntax
Text binding<p>Hello {name}</p>
Expressions{price * qty}   {ok ? 'yes' : 'no'}
Events<button onclick={add}> — handler receives the DOM event
Dynamic attributes<button :disabled="count >= 10"> — booleans toggle, others set
Attribute interpolation<input value="{draft}">
Loops<template each="todo in todos">…</template>
Loops with index<template each="todo, i in todos">…</template>
Keyed loops<template each="row in rows" key="row.id">…</template> — reuse nodes by identity across reorders
Conditional display<div :hidden="!show">
Dynamic class<li :class="done ? 'item done' : 'item'">
Two-way bindingbind:value (text, number, <select>, contenteditable), bind:checked (checkbox), bind:group (radio)
Conditional blocks<template if="show">…</template> — nodes enter/leave the DOM
Else branches<template else-if="…"> / <template else> — chained directly after an if; the first truthy branch renders
Async blocks<template await="promise">…</template> — pending / then / catch
Name the awaited value<template await="load(id)" as="user"> — use {user.name} instead of {await.name}
Slots<slot></slot>   <slot name="title"></slot> — project caller content
Escape hatch<pre spark-ignore> — subtree is never patched; literal {braces} survive

Loops live on <template> so they're valid HTML anywhere — including inside <ul> and <table>. Loops reconcile rather than rebuild — existing rows keep their DOM nodes (and focus, scroll, and CSS transitions) across updates, so you can safely place a bind:value input inside a loop. Add key="…" when items reorder, to reuse nodes by identity.

Literal braces

Spark reads {…} in any text as an expression. To show a literal brace, escape it with a backslash\{ and \}:

<p>press \{enter\} to submit</p>   <!-- renders: press {enter} to submit -->

HTML entities like &#123; do not work — the browser decodes them back to { before Spark sees the text. For a whole block of literal content (code samples, docs), use the spark-ignore attribute so Spark skips the subtree entirely:

<pre spark-ignore>
  function greet() { return `Hi ${name}`; }
</pre>

A string expression also works for one-off braces: {'{'} renders {. The backslash escape is usually cleaner in prose.

Conditional chains (if / else-if / else)

Consecutive sibling templates form a chain: one if head, any number of else-if branches, and an optional bare else. Exactly one branch renders — the first whose expression is truthy, or the else when none is:

<template if="score > 90"><p>Excellent</p></template>
<template else-if="score > 60"><p>Passed</p></template>
<template else><p>Try again</p></template>

Branches must follow each other directly (whitespace and comments between are fine — other content breaks the chain). Expressions short-circuit like real if/else: branches after the active one aren't evaluated. Works inside each rows, nested chains, and with prerender and enter/leave transitions (spark-html-motion) like a plain if.

Async blocks (<template await>)

Render a promise declaratively — loading, resolved, and error states — with no manual flags:

<template await="loadUser(id)">
  <p>Loading…</p>                                <!-- pending -->
  <template then> <h1>Hi {await.name}</h1> </template>  <!-- resolved -->
  <template catch> <p>Failed: {await.message}</p> </template> <!-- error -->
</template>

Inside <template then>, the identifier await holds the resolved value. Inside <template catch>, it holds the error. Give it a custom name with as="…":

<template await="loadUser(id)" as="user">
  <p>Loading…</p>
  <template then>  <h1>Hi {user.name}</h1>          </template>
  <template catch> <p>Failed: {user.message}</p>   </template>
</template>

The expression is reactive — re-evaluated when state changes, cancelling the prior promise. Use await="once(expr)" to fire on mount only (no re-evaluation). A plain (non-promise) value renders the then branch immediately. The spark-prerender build tool waits for the promise to settle before writing the output, so async data lands in the HTML.

Slots

Slots let a component wrap content the caller provides — the key to reusable wrappers like cards, modals, and layouts. A component marks insertion points with the real <slot> element:

<!-- components/card.html -->
<article class="card">
  <header><slot name="title">Untitled</slot></header>
  <div class="body"><slot>Nothing here yet.</slot></div>
</article>

Whatever you write between the import tags is projected in — targeted by slot="name", with everything else filling the default slot:

<div import="components/card">
  <span slot="title">{heading}</span>
  <p>Hello {user.name} — you have {count} messages.</p>
  <button onclick="{refresh}">Refresh</button>
</div>

Projected content keeps the parent's scope: its {interpolations}, bind:, and onclick handlers resolve where you wrote them, and stay reactive to the parent. A <slot> with no provided content renders its own children as fallback.

Reactive statements & lifecycle

Derive values with $: — they re-run automatically whenever component state changes:

<p>{count} doubled is {doubled} — that's {label}</p>

<script>
  let count = 3;
  $: doubled = count * 2;
  $: label = doubled > 8 ? 'big' : 'small';
</script>

A $: statement can span multiple lines — a ternary, a chained call, or a callback broken across lines all work.

Run code after the component is in the DOM with the onMount builtin. Return a function to register a cleanup hook — it runs when the component leaves the DOM (removed by an if/each block, or via unmount()):

<script>
  let data = [];
  onMount(async () => {
    const id = setInterval(tick, 1000);
    data = await fetch('/api/items').then(r => r.json());
    return () => clearInterval(id);   // cleanup on unmount
  });
</script>

Updates are batched: several assignments in one event handler (and all $: recomputations) collapse into a single DOM patch on the next microtask, so a handler that touches ten variables still patches once.

JS imports

Use standard import statements directly inside a component's <script> tag. Spark lifts them out and replays them as dynamic import() calls resolved relative to the component file — no bundler needed:

<!-- components/user-card.html -->
<h2>{greeting}</h2>

<script>
  import { capitalize } from '../lib/format.js';
  let name = 'spark';
  let greeting = `Hello, ${capitalize(name)}!`;
</script>

All import forms work: named (with aliases), default, namespace, and side-effect:

<script>
  import utils from '../lib/utils.js';            // default
  import { add, multiply } from '../lib/math.js'; // named
  import { format as fmt } from '../lib/text.js'; // aliased
  import * as api from '../lib/api.js';           // namespace
  import '../lib/setup.js';                       // side-effect
</script>

Relative (./, ../) and root-absolute (/lib/…) paths resolve against the component file's URL, not the page. Bare specifiers (import confetti from 'canvas-confetti') are left to the browser, so an import map resolves them — still no build step.

Imports work with $:, props, and everything else — imported functions are just regular JavaScript, and imported values live in component state:

<script>
  import { multiply, factorial } from '../lib/math.js';
  export let a = 6;
  export let b = 7;
  $: product = multiply(a, b);
  $: fact = factorial(a);
</script>

A script with imports runs as an async function, so top-level await works in it too:

<script>
  import { fetchGreeting } from '../lib/api.js';
  let data = await fetchGreeting('Spark');
</script>

Components stay flash-free: a component with imports is revealed only after its modules load, and mount() resolves when everything is booted. During prerender, imports execute for real (loaded from disk with Node's module loader), so the prerendered HTML contains the actual computed values.

Props

Declare props with export let. The value after = is the default:

<!-- components/profile.html -->
<h2>{name}{admin ? ' · admin' : ''}</h2>

<script>
  export let name = 'Anonymous';
  export let age = 0;
  export let admin = false;
</script>

Pass props as attributes on the import placeholder:

<div import="components/profile" name="Ada Lovelace" age="36" admin></div>

Attribute values are coerced: "36" becomes the number 36, "true"/"false" become booleans, a bare attribute becomes true, and valid JSON (items='["a","b"]') is parsed. Anything else stays a string.

Variables declared with plain let are private — attributes cannot override them. class and id on the placeholder keep their normal HTML meaning and are copied to the component's host element instead of becoming props.

Stores

Stores are named, shared, reactive objects — the way separate components talk to each other. Create them in app code before mounting:

// main.js
import { mount, store } from 'spark-html';

store('cart', { items: [], total: 0 });
mount();

Subscribe from any component with the useStore builtin (available in every component script, no import needed):

<p>{cart.items.length} items — ${cart.total}</p>

<script>
  const cart = useStore('cart');

  function add(item, price) {
    cart.items = [...cart.items, item];
    cart.total = cart.total + price;
  }
</script>

Every property assignment on a store re-patches all subscribed components. Subscriptions are cleaned up automatically for components no longer in the DOM.

Derived stores

derived(name, deps, compute) is a read-only store computed from other stores — the cross-component answer to a component-local $:. It recomputes when any source changes and only notifies subscribers when a key actually changes (memoized):

// main.js
import { store, derived } from 'spark-html';

store('cart', { items: [] });
derived('cartTotal', ['cart'], (cart) => ({
  count: cart.items.length,
  total: cart.items.reduce((s, i) => s + i.price, 0),
}));

Read it like any store. compute returns an object whose keys become the derived state; derived stores may even depend on other derived stores. Mutate a source store, never the derived proxy.

<p>{summary.count} items — ${summary.total}</p>
<script>const summary = useStore('cartTotal');</script>

Forms

bind:form="name" on a <form> creates a reactive name object — { valid, errors, values, pending, submitted, error } — driven by native HTML constraint validation (required, type="email", pattern, minlength…). Submit is auto-preventDefault'd; an async onsubmit handler is awaited with pending and a rejection is caught into error. No manual flags:

<form bind:form="f" onsubmit={save} novalidate>
  <input name="email" type="email" required bind:value="email" />
  <p :hidden="!(f.submitted && f.errors.email)">{f.errors.email}</p>

  <button type="submit" :disabled="f.pending || !f.valid">
    {f.pending ? 'Saving…' : 'Sign up'}
  </button>
  <p :hidden="!f.error">✗ {f.error?.message}</p>
</form>
<script>
  let email = '';
  async function save() { await api.signup(email); }  // f.pending wraps this
</script>

A plain onsubmit={…} on a <form> (without bind:form) is auto-preventDefault'd too — no accidental full-page navigation.

Async data

For data fetching, the optional spark-html-query package adds a self-fetching store on top of store()loading / error / data / fetching / refetch / mutate — read with the same useStore:

// main.js
import { query } from 'spark-html-query';
query('user', () => fetch('/api/user').then((r) => r.json()));
<p :hidden="!user.loading">Loading…</p>
<h1 :hidden="user.loading">{user.data?.name}</h1>
<button onclick="{user.refetch}">Reload</button>
<script>const user = useStore('user');</script>

It pairs with derived() — shape a query into exactly what a view needs, memoized, and it updates as the request settles.

JavaScript API

ExportDescription
mount(root?)Resolve imports and boot all components. root is a selector or element; defaults to document.body. Returns a promise.
unmount(el)Tear down a mounted subtree: run its onMount cleanups and drop its store subscriptions. Call before removing a component you mounted imperatively.
store(name, initial?)Create (or retrieve) a named store. Returns the reactive store object.
derived(name, deps, compute)Create a read-only store computed from other stores (by name). Recomputes on source change, memoized per key. Returns the derived store object.
component(name, source)Register a component from a source string instead of a file. <div import="name"> then resolves to it — useful for tests and inline components.
parseSFC(source)Split component source into { markup, script, style }.
evaluate(expr, scope) / interpolate(tpl, scope)The low-level expression engine, exported for testing.

Inside component scripts, the builtins useStore(name) and props (the raw props object) are always in scope.

Vite plugin

// vite.config.js
import { defineConfig } from 'vite';
import spark from 'spark-html/vite';

export default defineConfig({
  plugins: [spark()],          // options: { componentsDir: 'components' }
});

The plugin serves component fragments raw (so Vite doesn't inject HMR scripts into them) and triggers a full page reload whenever a component file changes. Spark itself has no hard Vite dependency — any static file server works.

Editor support

Spark components are plain .html files, so they syntax-highlight out of the box. For full {interpolation} highlighting there are extensions for Zed and VS Code.

Format on save (Zed)

Spark mixes HTML-style attributes (:value="x", onclick="{fn}") with {interpolations}, and the stock Prettier html/svelte parsers both corrupt that. So Spark ships prettier-plugin-spark — it formats the <script> and <style> blocks and leaves your markup byte-for-byte intact, so {…} and onclick="{…}" are never mangled.

The Zed extension already declares the parser; enable Prettier + the plugin once in your Zed settings.json (Zed bundles Prettier and installs the plugin for you), and format-on-save just works:

{
  "languages": {
    "Spark": {
      "prettier": {
        "allowed": true,
        "plugins": ["prettier-plugin-spark"]
      }
    }
  }
}

Outside Zed, add the plugin to your .prettierrc with a *.html override that sets "parser": "spark", then run npx prettier --write.

How it works

1 — Text-level parsing. When a component is fetched, <script> and <style> are extracted from the raw text before the markup ever touches innerHTML. Browsers neuter script tags injected via innerHTML, so DOM-based extraction is unreliable by design — Spark sidesteps the whole class of bugs.

2 — Proxy scopes. The script's top-level declarations are collected, the declarations are rewritten to bare assignments, and the code runs inside with(proxy). Every assignment hits the proxy's set trap, which re-patches that component's subtree. Objects and arrays read from scope come back wrapped in a thin reactive proxy, so in-place mutation (todos.push(x), row.done = true) re-renders too — only plain objects/arrays are wrapped, so Dates and class instances are left alone. Window built-ins like name or status can't shadow your state — the scope only claims identifiers your script declared.

3 — In-place patching. Text nodes and attributes cache their original template (and parsed bindings) on first visit, then diff against the interpolated result on every patch. Loops keep one block of nodes per item and reconcile them — reusing nodes in place (by index, or by key when given), creating only what's new and removing only what's gone. No virtual DOM and no full-tree diff — just direct, scoped writes against the real nodes, batched onto a single microtask flush.

4 — Only what changed. A patch does work proportional to the changed state, not the size of the component. Static subtrees (no bindings, loops, conditionals, or nested components) are walked once and then skipped entirely. And while each binding and $: statement is evaluated, the proxy records which scope keys it read — so a plain count++ re-evaluates only the bindings and reactive statements that read count. That precision reaches into loops: mutating one row's data (todos[i].done = true) re-walks just that row, not the whole list — a 1,000-row table where one cell changes does one row's work, not a thousand, while a $: aggregate over the list and any direct out-of-loop read stay correct. Changes that still can't be pinned to a row or key — a structural array change (todos.push), a non-loop deep mutation, store notifications, and member-path two-way writes — fall back to a full (still cheap) pass, so the UI is never stale.

5 — No flash on load. A cloak style hides Spark-managed elements until each component is booted, styled, and patched — so the raw {braces} and unstyled markup never flash before the runtime catches up.

Debugging

When something doesn't render, check the console — Spark warns (prefixed [spark]) instead of failing silently:

  • A throwing {expression} renders empty and warns with the exact expression (use {a?.b} for values that may be missing).
  • A syntax error in an expression or <script> names the offending code; a failed <script> tells you the component's state and handlers are unavailable.
  • A malformed each, an each over a non-array, or a missing import each warn with the likely fix.

Warnings are deduplicated — each distinct problem is reported once, so a broken binding won't flood the console on every keystroke.

Error handling

Failures are isolated to the component that caused them — a broken component never blanks the page or stops a sibling from rendering. Broken expressions, $: statements, event handlers, a <script> that throws, boot, and patch are all caught and logged (deduped) with the component named.

For development, opt into a full-screen error overlay (message, failing component, and stack) — off by default:

import { mount } from 'spark-html';
mount(document.body, { devOverlay: true });

Prerender for SEO

Spark renders on the client, so a crawler that doesn't run JS sees empty placeholders. The companion spark-prerender package runs the real runtime against a server DOM and writes back fully-rendered HTML — {interpolations} resolved, each/if and nested imports rendered, scoped styles inlined, and <title>/<meta> injected from component state. The output keeps its import placeholders, so the client re-renders over it (hydrates) with no blank.

# post-build step — multi-page is just a list (no router)
npx spark-prerender dist/index.html dist/docs.html

Metadata needs no special API — declare it as component state, and async data lands in the HTML via an async load() hook:

<script>
  let pageTitle = 'Spark — HTML that reacts!';
  let pageDescription = 'Single-file HTML components with built-in reactivity.';
  let photos = [];
  async function load() { photos = await (await fetch('/api/photos')).json(); }
</script>

There's also a Vite plugin (spark-prerender/vite) that prerenders dist/*.html automatically on build. See the package README for options.

Limitations

Spark trades completeness for simplicity. Know the edges:

LimitDetail
One scope per componentAll top-level declarations share the component scope. JS import statements are supported (transformed to dynamic imports); import.meta is not available inside component scripts.
Block scopinglet/const with a simple identifier inside functions hoist to component scope. Destructuring declarations are untouched and stay local.
Reactivity depthPlain objects/arrays are deeply reactive (mutation re-renders). Map/Set/class instances are not tracked — reassign them to update.
Loop reconciliationReuse is positional by default; reordering a list without a key keeps nodes in place and rewrites their contents. Add key="…" for identity-stable moves.
Coarse store updatesStores are deeply reactive (an in-place cart.items.push(x) notifies every subscriber), but each notification re-renders subscribers with a full (cheap) pass — store reads aren't tracked per key the way component-local state is. For shared computed values, derived() recomputes once and only notifies when a key actually changes (memoized).