Tutorials — learn Spark by editing real components
Every step below is a real .html Spark component running natively — no sandbox, no compiler, the same runtime as production. Edit the source and the preview updates as you type. Zero to productive in an afternoon.
1.
▸ Try:
lesson-1.html — edit me
● live preview — the component above, mounted for real
✘
Live demos — every demo runs the exact code shown
Core — spark-html
● live · bind:value, each loops with index, $: derived, add & remove
- {todo}
- Ship it
- Star the repo
components/demo-todos.html — exactly as it runs (style omitted)
<input bind:value="draft" placeholder="Add a todo…" />
<button class="add" :disabled="!draft.trim()" onclick="{add}">Add</button>
<ul>
<template each="todo, i in todos">
<li>
<span>{todo}</span>
<button class="x" onclick="{remove}" :data-i="i">✕</button>
</li>
</template>
</ul>
<p class="meta">{summary}</p>
<script>
let todos = ['Ship it', 'Star the repo'];
let draft = '';
$: summary = todos.length + (todos.length === 1 ? ' item' : ' items');
function add() {
if (!draft.trim()) return;
todos = [...todos, draft.trim()];
draft = '';
}
function remove(event) {
const i = Number(event.target.dataset.i);
todos = todos.filter((_, idx) => idx !== i);
}
</script>
● live · derived values recompute on every change
3 × 2 = 6 — that's small
components/demo-reactive.html — exactly as it runs (style omitted)
<p class="readout">
{count} × 2 = <b>{doubled}</b> — that's <b>{label}</b>
</p>
<button class="add" onclick="{inc}">count++</button>
<script>
let count = 3;
$: doubled = count * 2;
$: label = doubled > 10 ? 'big 🔥' : 'small';
function inc() { count++; }
</script>
● live · template if/else mounts and unmounts real DOM
- {i + 1}. {fruit}
- {i + 1}. {fruit}
- 1. 🍋 lemon
- 2. 🍑 peach
- 3. 🫐 blueberry
- 4. 🥝 kiwi
components/demo-ifeach.html — exactly as it runs (style omitted)
<button class="add" onclick="{toggle}">{open ? 'Hide' : 'Show'} the list</button>
<button class="ghost" onclick="{shuffle}" :disabled="!open">Shuffle</button>
<template if="open">
<ul>
<template each="fruit, i in fruits">
<li><span class="idx">{i + 1}.</span> {fruit}</li>
</template>
</ul>
<p class="meta">{fruits.length} items</p>
</template>
<template else>
<p class="meta">list unmounted — the nodes are gone</p>
</template>
<script>
let open = true;
let fruits = ['🍋 lemon', '🍑 peach', '🫐 blueberry', '🥝 kiwi'];
function toggle() { open = !open; }
function shuffle() { fruits = [...fruits].sort(() => Math.random() - 0.5); }
</script>
● live · template await/then/catch — async as markup
⏳ loading…
👤 {await.name} — {await.role}
✘ {await.message} (every 4th try fails — try again)
👤 Ada #1 — engineer
components/demo-await.html — exactly as it runs (style omitted)
<button class="add" onclick="{reload}">Fetch again</button>
<template await="data">
<p>⏳ loading…</p>
<template then>
<p>👤 <b>{await.name}</b> — {await.role}</p>
</template>
<template catch>
<p>✘ {await.message}</p>
</template>
</template>
<script>
// the block re-fires whenever `data` is reassigned; an in-flight
// older promise is cancelled automatically
function load(n) {
return new Promise((resolve, reject) => {
const done = () => n % 4 === 0
? reject(new Error('flaky network'))
: resolve({ name: `Ada #${n}`, role: 'engineer' });
// instant at prerender, 700ms "network" live
globalThis.__SPARK_PRERENDER__ ? done() : setTimeout(done, 700);
});
}
let attempt = 1;
let data = load(1);
function reload() { attempt++; data = load(attempt); }
</script>
● live · two instances, one named store — no providers, no prop drilling
instance A
shared sparks:
instance B
shared sparks:
main.js + components/demo-store.html — exactly as it runs (style omitted)
// main.js
import { store } from 'spark-html';
store('playground', { sparks: 0 });
<!-- demo-store.html — mounted twice, with tag="A" / tag="B" -->
<p class="who">instance <b>{tag}</b></p>
<p class="readout">shared sparks: <b>{pg.sparks}</b></p>
<button class="add" onclick="{pg.sparks++}">+1 from {tag}</button>
<script>
export let tag = 'A';
const pg = useStore('playground');
</script>
● live · onMount with a cleanup hook
mounted at …
alive for 0s
components/demo-lifecycle.html — exactly as it runs (style omitted)
<p class="row">mounted at <b>{mountedAt}</b></p>
<p class="row">alive for <b>{alive}s</b></p>
<script>
let mountedAt = '…';
let alive = 0;
onMount(() => {
mountedAt = new Date().toLocaleTimeString();
const handle = setInterval(() => { alive++; }, 1000);
return () => clearInterval(handle); // cleanup hook
});
</script>
● live · async state via setInterval
0s
components/demo-timer.html — exactly as it runs (style omitted)
<h3 class="clock">{seconds}s</h3>
<button class="add" :disabled="running" onclick="{start}">Start</button>
<button class="add" :disabled="!running" onclick="{stop}">Stop</button>
<button class="ghost" onclick="{reset}">Reset</button>
<script>
let seconds = 0;
let running = false;
let handle = null;
function start() { running = true; handle = setInterval(() => { seconds++; }, 1000); }
function stop() { running = false; clearInterval(handle); }
function reset() { stop(); seconds = 0; }
</script>
● live · export let + attribute coercion
usage + components/badge.html — exactly as it runs (style omitted)
<div import="components/badge" label="Passed as attribute" tone="violet"></div>
<div import="components/badge" label="Numbers coerce" count="42" tone="gold"></div>
<div import="components/badge"></div>
<div :class="'badge ' + tone">
<strong>{label}</strong>
<span class="count" :hidden="!count">{count}</span>
</div>
<script>
export let label = 'Default badge';
export let count = 0;
export let tone = 'plain';
</script>
The ecosystem — one tiny package per job
● live · spark-html-router — route.query is reactive, in both directions
route.path =
route.query = {}
components/demo-router.html — exactly as it runs (style omitted)
<p>route.path = <b>{route.path}</b></p>
<p>route.query = <b>{JSON.stringify(route.query || {})}</b></p>
<button onclick="{next}">?page={Number(route.query?.page || 1) + 1}</button>
<button onclick="{search}">?q=spark</button>
<button onclick="{clear}">clear query</button>
<script>
const route = useStore('route');
function next() { route.query.page = String(Number(route.query.page || 1) + 1); }
function search() { route.query.q = 'spark'; }
function clear() { navigate(location.pathname); }
</script>
● live · spark-html-head — the head store drives the real document title
components/demo-head.html — exactly as it runs (style omitted)
<input bind:value="draft" placeholder="Spark — Playground" />
<p>head.title = <b>{head.title || '(config default)'}</b></p>
<script>
const head = useStore('head');
let draft = '';
// components own their metadata; overrides clear on the next route change
$: head.title = draft ? `${draft} · Spark` : undefined;
</script>
● live · spark-html-theme — the store this whole site themes with
mode: · resolved:
main.js + components/demo-theme.html — exactly as it runs (style omitted)
// main.js — one line
import { theme } from 'spark-html-theme';
theme();
<!-- demo-theme.html -->
<p>mode: <b>{theme.mode}</b> · resolved: <b>{theme.resolved}</b></p>
<button onclick="{theme.toggle}">toggle</button>
<button onclick="{setMode}" data-mode="light">light</button>
<button onclick="{setMode}" data-mode="dark">dark</button>
<button onclick="{setMode}" data-mode="system">system</button>
<script>
const theme = useStore('theme');
function setMode(event) { theme.set(event.target.dataset.mode); }
</script>
● live · spark-html-motion — enter/leave transitions on if/each blocks
main.js + components/demo-motion.html — exactly as it runs (style omitted)
// main.js — register once, before mount()
import { motion } from 'spark-html-motion';
motion();
<!-- demo-motion.html — opt elements in with `transition` -->
<template if="open">
<div class="panel-demo" transition="fade">panel</div>
</template>
<template each="c in cards">
<div class="mini" transition="slide" transition-duration="250">#{c}</div>
</template>
<script>
let open = true;
let cards = [1, 2];
let n = 2;
function add() { n++; cards = [...cards, n]; }
function pop() { cards = cards.slice(0, -1); }
function toggle() { open = !open; }
</script>
● live · spark-html-query — loading/fetching/error/data as plain store state
⏳ loading…
🎲 refetching…
✘
main.js + components/demo-query.html — exactly as it runs (style omitted)
// main.js — a self-fetching reactive store
import { query } from 'spark-html-query';
query('pg-fact', flakyFactFetcher); // resolves in ~900ms, every 5th call rejects
<!-- demo-query.html -->
<p :hidden="!fact.loading">⏳ loading…</p>
<p :hidden="fact.loading">
🎲 <b>{fact.data?.value}</b>
<span :hidden="!fact.fetching">refetching…</span>
</p>
<p :hidden="!fact.error">✘ {fact.error?.message}</p>
<button onclick="{fact.refetch}">refetch()</button>
<button onclick="{optimistic}">mutate()</button>
<script>
const fact = useStore('pg-fact');
function optimistic() { fact.mutate({ value: 'optimistic!' }); }
</script>
● live · spark-html-persist — a store that survives reloads. Try it: reload now.
visits to this demo: 0
main.js + components/demo-persist.html — exactly as it runs (style omitted)
// main.js — a normal store(), but persistent
import { persist } from 'spark-html-persist';
persist('pg-prefs', { opens: 0, note: '' });
<!-- demo-persist.html -->
<p>visits to this demo: <b>{opens}</b></p>
<input bind:value="prefs.note" placeholder="type, then hit reload" />
<script>
const prefs = useStore('pg-prefs');
let opens = 0;
onMount(() => {
prefs.opens++; // saved to localStorage immediately
opens = prefs.opens; // local mirror for this render
});
</script>
● live · spark-prerender — this site's own build artifacts
This very site is prerendered — every route ships as real HTML, plus:
spark.config.js — the whole SEO setup
import prerender from 'spark-prerender/bun';
export default {
pipeline: [
prerender({
site: 'https://wilkinnovo.github.io/spark-html',
// routes are auto-detected from <template route> blocks;
// emits one rendered HTML file per route
// + 404.html + sitemap.xml + robots.txt
}),
],
};
◆ build-time · spark-html-image — what the plugin does to your markup
you write
<img src="/hero.jpg" alt="Hero" />
the build ships
<picture> <source type="image/avif" srcset="/hero-480.avif 480w, /hero-960.avif 960w, /hero-1920.avif 1920w" /> <source type="image/webp" srcset="/hero-480.webp 480w, /hero-960.webp 960w, /hero-1920.webp 1920w" /> <img src="/hero.jpg" alt="Hero" width="1920" height="1080" loading="lazy" decoding="async" /> </picture>
spark.config.js — zero further config
import image from 'spark-html-image/bun';
export default {
pipeline: [
image(), // every <img> in pages and components:
// · converted to webp + avif at multiple widths
// · wrapped in <picture> with srcset
// · width/height added (no layout shift)
// · loading="lazy" decoding="async"
],
};
● live · spark-html-devtools — run the real inspector on this site, right now
inspector: off
pokes: 0 — watch the patch counter tick and this component flash amber
main.js — dev only, one line
import { devtools } from 'spark-html-devtools';
if (import.meta.env?.DEV) devtools();
// A ⚡ panel (bottom-right) shows:
// · Stores — every named store, live state
// · Components — the tree, each component's reactive state
// · Patches — a counter + an amber outline on whichever
// component just re-rendered, so the surgical
// reactivity is VISIBLE
//
// const stop = devtools({ open: true }); stop() removes it.
● live · spark-html-offline — the worker's actual routing rule
– passed through untouched
main.js + spark.config.js — the whole setup
// main.js — zero config
import { offline } from 'spark-html-offline';
offline();
// spark.config.js — writes + serves /spark-sw.js
import offlineSw from 'spark-html-offline/bun';
pipeline: [offlineSw()]
// Now this works on a plane:
// <div import="https://esm.sh/some-pkg/card.html"></div>
//
// cache-first after the first visit, refreshed in the
// background — CDN down ≠ component gone.
● live · spark-html-sri — tamper with a "shipped" component and watch it get blocked
integrity: …
vs. shipped manifest: …
main.js + spark.config.js — the whole setup
// main.js — before mount()/router()
import { sri } from 'spark-html-sri';
sri(); // verifies every component fetch against the
// manifest the build baked into the page
// spark.config.js — sri() runs last, after prerender()
import sriPlugin from 'spark-html-sri/bun';
pipeline: [prerender(), sriPlugin()]
// Remote imports: allow list + trust-on-first-use —
// sri({ allow: ['esm.sh', 'unpkg.com'] })
// a CDN compromised after your first visit serves bytes
// that no longer hash → blocked before they run.
● live · spark-html-manifest — the exact manifest the plugin would emit
spark.config.js — a full PWA from one config
import manifest from 'spark-html-manifest/bun';
pipeline: [prerender(), manifest({
name: 'My Spark App',
themeColor: '#ffd24a',
icon: 'public/icon.png', // one image → 192 + 512 png
offline: true, // + offline app-shell worker
})]
// build output:
// manifest.webmanifest · icons/spark-192.png ·
// icons/spark-512.png · spark-manifest-sw.js
// + <link rel="manifest"> <meta name="theme-color">
// injected into every page