Storybook for htmx & hypermedia. One binary, zero JS toolchain.
Quickstart · Demos · Docs · How it works · Protocol · Examples · Roadmap
Ever needed to see what your htmx partial looks like in its error state, the one
that only renders after a 422? Or its empty state, or the "a row just got
added" state? In a running app you either trigger each one for real, or fake a
broken backend and remember to undo it.
Swapbook renders those states in isolation. Point the binary at your running app and it previews any component in any state through your real htmx, with mock responses so you can hit the error swap without touching your backend, plus an inspector that logs every request and swap.
It is framework-agnostic: the binary speaks a tiny HTTP protocol, so it works with Go/templ, Django, Rails, Laravel, Flask, Express, or a plain server, with no per-framework requirement and no Node build step.
swapbook --target :8080
# open http://localhost:7007/__sb/
# prebuilt binary (macOS / Linux)
curl -fsSL https://raw.githubusercontent.com/Aejkatappaja/swapbook/main/install.sh | sh
# or run without installing (Node)
npx swapbook --target :8080
# or with Go
go install github.com/Aejkatappaja/swapbook/cmd/swapbook@latest
Prebuilt binaries for macOS, Linux and Windows (amd64 / arm64) are attached to every release.
Storybook is built around JS components; its HTML/Server modes are awkward and drag in a JS toolchain, the opposite of why you reached for htmx. The good server-rendered tools that exist are locked to one framework: Lookbook (Rails), phoenix_storybook (LiveView), django-lookbook (Django).
Swapbook fills the gap: a single tool for any hypermedia backend, with the parts
a hand-rolled /preview page can't give you.
- One binary. Static assets embedded. No
node_modules, no config file. - htmx-aware inspector. See every request, its params, status, timing, the swap target highlighted in the preview, and the returned HTML.
- Three interaction modes.
mock(serve canned responses, no auth/DB),safe(real requests, mutations blocked),live(everything real). - Live controls. Edit a component's props from the toolbar and re-render.
- a11y lint, search, deep-links, keyboard-driven.
The gallery UI is plain JavaScript, type-checked with JSDoc and checkJs in CI,
not TypeScript: the whole tool is zero-build, so a compile step would defeat the
point. The binary itself is Go with no dependencies.
Add the adapter and register your components:
import adapter "github.com/Aejkatappaja/swapbook/adapters/go"
func Workbench() http.Handler {
reg := adapter.New()
reg.HTMXSrc = "/static/htmx.min.js" // your app's htmx
reg.CSSSrc = "/static/app.css" // injected into bare-fragment previews
reg.JSSrc = "/static/app.js"
reg.RegisterIn("forms", "Workout Form",
adapter.Var("new", WorkoutForm(user, store.Workout{}, "", "/app/workouts", "new")),
// live controls: edit props from the UI
adapter.VarC("controls", []adapter.Control{
{Name: "heading", Type: "text", Default: "new workout"},
{Name: "errMsg", Type: "text", Default: ""},
}, func(a adapter.Args) adapter.Renderer {
return WorkoutForm(user, store.Workout{}, a.String("errMsg"), "/app/workouts", a.String("heading"))
}),
// mock a route so interactions run with no auth/DB
adapter.Var("empty", WorkoutForm(...)).
Mock("GET /app/workouts/entry-row", EntryRow(store.WorkoutEntry{})),
)
return reg.Handler()
}Mount it (dev only) and run Swapbook against your app:
mux.Handle(adapter.MountPath+"/", http.StripPrefix(adapter.MountPath, Workbench()))swapbook --target :8080
The Go adapter takes any Renderer (Render(ctx, io.Writer) error), so templ,
html/template, and gomponents all work. The Swapbook module itself has zero
dependencies.
from swapbook_adapter import Registry, control, variant
reg = Registry(css_src="/static/app.css")
reg.register("Button", group="actions", variants=[
variant("primary", lambda a: render_button("Save", "primary")),
variant("controls", lambda a: render_button(a["label"], a["variant"]), controls=[
control("label", "text", "Save"),
control("variant", "select", "primary", options=["primary", "secondary"]),
]),
])
urlpatterns = reg.urls # mounts /_swapbook/*A variant is any callable (args) -> html str, so plain templates,
django-components and cotton all work. Django 3.2+.
require_relative "swapbook"
REG = Swapbook::Registry.new(css_src: "/assets/app.css")
REG.register("Button", group: "actions", variants: [
Swapbook.variant("primary", ->(a) { render_button("Save", "primary") }),
Swapbook.variant("controls", ->(a) { render_button(a["label"], a["variant"]) }, controls: [
Swapbook.control("label", default: "Save"),
Swapbook.control("variant", type: "select", default: "primary", options: %w[primary secondary]),
]),
])
# in config/routes.rb: mount REG => "/_swapbook"A variant's render is a proc (args) -> HTML, so ActionView partials,
ViewComponent and Phlex all work. Rails 6+.
require 'swapbook.php';
$sb = new Swapbook();
$sb->cssSrc = '/css/app.css';
$sb->register('Button', [
sb_variant('primary', fn($a) => view('button', ['variant' => 'primary'])->render()),
sb_variant('controls', fn($a) => view('button', $a)->render(), [
sb_control('label', 'text', 'Save'),
sb_control('variant', 'select', 'primary', ['primary', 'secondary']),
]),
], 'actions');
// route all of /_swapbook/* to: $sb->handle($method, $path, $query)from swapbook import Registry, control, variant
reg = Registry(css_src="/static/app.css")
reg.register("Button", group="actions", variants=[
variant("primary", lambda a: render_button("Save", "primary")),
variant("controls", lambda a: render_button(a["label"], a["variant"]), controls=[
control("label", "text", "Save"),
control("variant", "select", "primary", options=["primary", "secondary"]),
]),
])
app.register_blueprint(reg.blueprint) # mounts /_swapbook/*A variant is any callable (args) -> html str, so Jinja templates and plain
strings both work. Flask 2+.
const { Registry, control, variant } = require("swapbook");
const reg = new Registry({ cssSrc: "/static/app.css" });
reg.register("Button", [
variant("primary", () => renderButton("Save", "primary")),
variant("controls", (a) => renderButton(a.label, a.variant), {
controls: [
control("label", "text", "Save"),
control("variant", "select", "primary", ["primary", "secondary"]),
],
}),
], { group: "actions" });
app.use(reg.router()); // mounts /_swapbook/*A variant's render is (args) -> HTML string, so any template engine works.
Requires express.
Any custom element renders as-is: return the element's HTML (plus its script tag) from a variant. The demos use @aejkatappaja/phantom-ui loaded from a CDN.
There is no adapter requirement. Answer four HTTP endpoints and you are done,
in any language. examples/{python,node,ruby}/ are dependency-free stdlib
targets that implement the protocol by hand. See SPEC.md.
examples/smoke.sh # boots Python/Node/Ruby targets behind Swapbook and checks them
Every stack ships a runnable demo of the same component design system, so you can see Swapbook drive each one. Bring them all up with Docker:
docker compose -f examples/docker-compose.yml up --build
swapbook --target :8000 # then point the binary at any target
| Stack | Container port | Demo |
|---|---|---|
| Django | :8000 |
full design system |
| Rails | :8001 |
full design system |
| Go | :8002 |
full design system |
| Laravel | :8003 |
full design system |
| Python / Node / Ruby | :9101 / :9102 / :9103 |
stdlib subset |
Or run a single stdlib target with no Docker: go run ./examples/go (then
swapbook --target :8000).
Swapbook runs as a transparent reverse proxy in front of your app, with its UI
mounted under /__sb/. Any request that isn't part of the UI is proxied to your
app unchanged, so htmx requests fired from a preview reach your app same-origin,
with no CORS and no URL rewriting. It also strips X-Frame-Options /
frame-ancestors from proxied responses so previews can be framed.
The protocol is four endpoints on your app under /_swapbook:
| Endpoint | Purpose |
|---|---|
GET /manifest.json |
stories, variants, control schemas, asset hints |
GET /preview/{id}/{variant} |
render a component (accepts control args) |
GET /mocks/{id}/{variant} |
list a variant's mocked routes |
ANY /mock/{id}/{variant}/{i} |
render a mock response |
Only the first two are required. Full details in SPEC.md.
Full guides live in docs/guide/:
- Getting started: install, run, mount the adapter.
- Writing stories: register components in Go, Django, Rails, Laravel or any stack.
- Controls, mocks and modes: live knobs, canned responses, the mock / safe / live gates.
- CLI reference: flags and usage.
- Adapters: built-in adapters and writing your own.
- Authoring an adapter: derive a new adapter for any stack from the protocol.
- Visual regression: screenshot variants and diff against a baseline in CI.
The protocol spec documents the four endpoints an app implements. Want to help? See CONTRIBUTING.md.
| Swapbook | Storybook (server) | Lookbook | phoenix_storybook | |
|---|---|---|---|---|
| Framework-agnostic | ✅ | partial | Rails | LiveView |
| No JS toolchain | ✅ | ❌ | ✅ | ✅ |
| Single binary | ✅ | ❌ | ❌ | ❌ |
| htmx-aware inspector | ✅ | ❌ | ❌ | ❌ |
| Mocked interactions | ✅ | via MSW | ❌ | ❌ |
Shipped: protocol · mock/safe/live modes · htmx inspector (with Turbo, Unpoly and
Datastar probes, verified end to end) · error-status mocks · controls · response
viewer · a11y lint · search · deep-links · keyboard nav · copy-as-curl ·
swap-target and out-of-band highlight · SSE / WebSocket inspector · canvas
background toggle · custom viewports · auth header injection for live-mode
previews · headless check for CI · visual regression · play functions
(scripted interactions + assertions) · per-component autodocs · auto-reload ·
install via curl / npx / go install.
Planned: Homebrew tap. Tracked in the roadmap.
Early. The protocol is proven across nine stacks with tests: Go/templ, Django, Rails, Laravel, Flask and Express via adapters, plus dependency-free Python, Node and Ruby targets. Interfaces may still shift before a tagged release.
Swapbook is early and deliberately scoped. Known edges:
- htmx is the verified path, any version. Previews load your app's own htmx
via
htmxSrc, so they run whatever version you ship (1.x or 2.x); the embedded fallback used whenhtmxSrcis unset is htmx 2.0.4. The Turbo, Unpoly and Datastar inspector probes are best-effort; they are exercised against the real libraries in a browser end-to-end test, but htmx remains the most complete path. - Auto-triggering components. A preview with
hx-trigger="load"or polling (every 2s) fires real GET requests to your app in mock and safe mode; only mutations are blocked. Mock those routes, or run against a dev database. - SSE and WebSocket connections are proxied through and the inspector logs
their lifecycle (open, each default
message/frame with direction, close). They are observed, not gated: unlike an HTTP request, a streamed connection is never mocked or blocked, and named SSE events are not yet surfaced. - Mocks are stateless. You can set a mock's status (200 by default, or a non-2xx like 422/500 to exercise error swaps), but it returns the same response on every call, so multi-step stateful flows are out of scope.
- Auth is global.
--headerinjects the same credential into every request forwarded to the target, so a session runs as one identity; there is no per-story auth. Use a throwaway dev session (it lands in your shell history), never a production token. - CSS. Bare-fragment previews load your app's declared
cssSrc. If your styles are code-split or purged per route, pointcssSrcat the full stylesheet so previews match the app. Full-page components bring their own. - Relative asset paths inside a bare fragment may not resolve; prefer
app-absolute paths like
/static/....
Swapbook strips security headers and proxies your app, so it is a local development tool only. Never run it in production or expose it publicly.
MIT.
