@beardcoder/stitch-js
v1.0.4
Published
A tiny, composable progressive enhancement framework for the browser.
Maintainers
Readme
stitch-js
A tiny, composable progressive enhancement framework for the browser.
Enhance existing HTML with interactive behavior — without a virtual DOM, SPA framework, or build step requirement.
Features
- Progressive enhancement first — HTML works without JS
- Generic component model —
defineComponentgives you scoped queries, delegated events, attribute parsing, and auto-cleanup - Reactive store —
createStore,computed, andeffectfor state management - Tree-shakeable — import only what you use
- Accessible — ARIA-aware utilities built in
- Tiny — ~2.7 kB min+gz full bundle, no dependencies
- TypeScript — strict types and great DX
- Composable — stack multiple behaviors on one element
- Idempotent — safe to call
enhance()multiple times
Bundle Size
All sizes are minified + gzipped. Tree-shaking ensures you only pay for what you import.
| Import | min | min+gz |
|---|--:|--:|
| Full bundle (everything) | 6,284 B | 2,709 B |
| Core only (enhance, defineComponent, etc.) | 2,130 B | 1,116 B |
| Store only (createStore, computed, effect) | 748 B | 361 B |
| Core + Tabs | 2,789 B | 1,387 B |
| Core + Accordion | 2,841 B | 1,393 B |
| Core + Form | 2,571 B | 1,352 B |
| Core + Animate | 2,156 B | 1,131 B |
Install
bun add @beardcoder/stitch-js
# or
npm install @beardcoder/stitch-jsQuick Start
<div data-tabs>
<div data-tab-list>
<button data-tab>One</button>
<button data-tab>Two</button>
</div>
<div data-tab-panel>Content one</div>
<div data-tab-panel>Content two</div>
</div>
<script type="module">
import { enhance, tabs } from "@beardcoder/stitch-js";
enhance("[data-tabs]", tabs());
</script>Core API
enhance(selector, factory, options?)
Find all elements matching selector and attach the component factory to each. Idempotent — calling twice on the same element is a no-op.
import { enhance } from "@beardcoder/stitch-js";
import { accordion } from "@beardcoder/stitch-js/components/accordion";
const instances = enhance("[data-accordion]", accordion({ multiple: true }));
instances.forEach((i) => i.destroy());Options:
root— scope the query to a subtree (default:document)options— override the factory options
destroyAll(selector, factory?, root?)
Destroy all enhanced instances on matching elements. Pass a factory to only remove that behavior.
register / init / autoInit
Declarative auto-initialization:
import { register, autoInit, tabs, accordion } from "@beardcoder/stitch-js";
register("[data-tabs]", tabs());
register("[data-accordion]", accordion());
autoInit(); // runs on DOMContentLoadeddefineComponent — Generic Component Builder
The core primitive for creating components. Provides a scoped ComponentContext with DOM queries, delegated event handling, attribute parsing, and automatic cleanup.
import { defineComponent, enhance } from "@beardcoder/stitch-js";
const toggle = defineComponent(
{ activeClass: "is-active" }, // typed defaults
(ctx) => {
ctx.on("click", () => {
ctx.el.classList.toggle(ctx.options.activeClass);
});
},
);
enhance("[data-toggle]", toggle());
enhance("[data-toggle]", toggle({ activeClass: "open" }));ComponentContext
Every setup function receives a ctx with these methods:
| Method | Description |
|---|---|
| ctx.el | The root HTMLElement |
| ctx.options | Resolved options (defaults + overrides) |
| ctx.query(selector) | querySelector scoped to root |
| ctx.queryAll(selector) | querySelectorAll scoped to root |
| ctx.attr(name, fallback?) | Read a data-* attribute |
| ctx.attrJson(name) | Read + JSON.parse a data-* attribute |
| ctx.on(event, handler) | Attach event listener (auto-cleanup) |
| ctx.on(event, selector, handler) | Delegated event listener (auto-cleanup) |
| ctx.aria(el, attrs) | Set aria-* attributes |
| ctx.uid(prefix?) | Generate a unique ID |
| ctx.onDestroy(fn) | Register cleanup callback |
| ctx.emit(name, detail?) | Dispatch a CustomEvent from root |
| ctx.sync(store, listener) | Subscribe to a store (auto-cleanup on destroy) |
| ctx.data<T>() | Read structured data from HTML (see below) |
The setup function can also return a cleanup function directly:
const counter = defineComponent({ start: 0 }, (ctx) => {
const interval = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(interval);
});ctx.data<T>() — Passing Data from HTML to JS
Pass structured data from server-rendered HTML into your components. This is essential for integrating external libraries (TanStack Table, charts, maps, etc.) that need configuration or datasets.
ctx.data<T>() reads from three sources in priority order:
<script type="application/json">— best for large payloads (column defs, row data, etc.)data-propsattribute — convenient for smaller inline JSON- All
data-*attributes — automatic fallback, camelCased keys
<!-- Option 1: Script tag for large/complex data -->
<div data-table>
<script type="application/json">
{
"columns": [
{ "header": "Name", "accessorKey": "name" },
{ "header": "Age", "accessorKey": "age" }
],
"rows": [
{ "name": "Alice", "age": 30 },
{ "name": "Bob", "age": 25 }
],
"endpoint": "/api/users"
}
</script>
<table><!-- server-rendered fallback rows --></table>
</div>
<!-- Option 2: data-props attribute for smaller payloads -->
<div data-chart data-props='{"type":"bar","labels":["A","B","C"]}'></div>
<!-- Option 3: Individual data attributes (auto-collected) -->
<div data-map data-lat="48.137" data-lng="11.576" data-zoom="12"></div>import { defineComponent, enhance } from "@beardcoder/stitch-js";
// TanStack Table example
const dataTable = defineComponent({}, (ctx) => {
const { columns, rows, endpoint } = ctx.data<{
columns: ColumnDef[];
rows: Row[];
endpoint: string;
}>();
const table = createTable({ columns, data: rows });
// render table into ctx.el ...
});
enhance("[data-table]", dataTable());Reactive Store
A minimal, dependency-free reactive primitive.
createStore(initial)
import { createStore } from "@beardcoder/stitch-js";
const count = createStore(0);
count.get(); // 0
count.set(1); // updates + notifies
count.update(n => n + 1); // transform + notify
const unsub = count.subscribe((value, prev) => {
console.log(`${prev} → ${value}`);
});
unsub(); // stop listeningcomputed(sources, derive)
Derived read-only store that auto-updates when sources change.
import { createStore, computed } from "@beardcoder/stitch-js";
const firstName = createStore("Jane");
const lastName = createStore("Doe");
const fullName = computed(
[firstName, lastName],
(first, last) => `${first} ${last}`,
);
fullName.get(); // "Jane Doe"
firstName.set("John");
fullName.get(); // "John Doe"effect(sources, fn)
Run a side-effect when sources change. Runs immediately, re-runs on change, and supports cleanup.
import { createStore, effect } from "@beardcoder/stitch-js";
const theme = createStore("light");
const stop = effect([theme], (value) => {
document.documentElement.dataset.theme = value;
return () => {
// cleanup before re-run or on stop
};
});
theme.set("dark"); // side-effect fires
stop(); // tear downCombining store with components
import { defineComponent, enhance, createStore, effect } from "@beardcoder/stitch-js";
const counter = defineComponent({ start: 0 }, (ctx) => {
const count = createStore(ctx.options.start);
const display = ctx.query("[data-count]");
const stopEffect = effect([count], (n) => {
if (display) display.textContent = String(n);
});
ctx.on("click", "[data-increment]", () => count.update(n => n + 1));
ctx.on("click", "[data-decrement]", () => count.update(n => n - 1));
ctx.onDestroy(stopEffect);
});
enhance("[data-counter]", counter());ctx.sync(store, listener) — Sync a store with a component
Subscribe to a store inside a component with automatic cleanup on destroy. The listener fires immediately with the current value and re-fires on every change.
import { defineComponent, enhance, createStore } from "@beardcoder/stitch-js";
const count = createStore(0);
const display = defineComponent({}, (ctx) => {
ctx.sync(count, (n) => {
ctx.el.textContent = String(n);
});
});
enhance("[data-display]", display());
count.set(5); // all [data-display] elements update to "5"persistedStore(key, initial, options?)
A reactive store that persists its value to localStorage (or sessionStorage)
and syncs across browser tabs via the storage event. It implements the same
Store interface, so it works with effect, computed, and ctx.sync.
import { persistedStore, effect } from "@beardcoder/stitch-js";
const theme = persistedStore("theme", "light");
theme.get(); // reads from localStorage, or falls back to "light"
theme.set("dark"); // updates localStorage + notifies subscribers
// Works with effect, computed, ctx.sync — just like createStore
effect([theme], (value) => {
document.documentElement.dataset.theme = value;
});Options:
| Option | Default | Description |
|---|---|---|
| storage | localStorage | Storage backend (localStorage or sessionStorage) |
| serialize | JSON.stringify | Custom value → string serializer |
| deserialize | JSON.parse | Custom string → value deserializer |
Router
A simple hash-based client-side router. Exposes a store-compatible interface
so it works with effect, computed, and ctx.sync.
createRouter(patterns?)
import { createRouter, effect } from "@beardcoder/stitch-js";
const router = createRouter(["", "about", "users/:id"]);
effect([router], (route) => {
console.log(route.path, route.params);
});
router.push("users/42"); // logs "users/42" { id: "42" }Route state:
| Property | Type | Description |
|---|---|---|
| path | string | The full hash path (without the leading #) |
| params | Record<string, string> | Named params extracted from the pattern |
| pattern | string | The matched pattern, or "" if none matched |
Methods:
| Method | Description |
|---|---|
| router.get() | Get the current route state |
| router.subscribe(fn) | Subscribe to route changes |
| router.push(path) | Navigate to a hash path |
| router.destroy() | Stop listening to hash changes |
Example Components
The built-in components are examples built on defineComponent. Use them directly or as reference for your own.
Tabs
<div data-tabs>
<div data-tab-list>
<button data-tab>Tab 1</button>
<button data-tab>Tab 2</button>
</div>
<div data-tab-panel>Panel 1</div>
<div data-tab-panel>Panel 2</div>
</div>enhance("[data-tabs]", tabs({ defaultIndex: 0 }));Options: listSelector, tabSelector, panelSelector, defaultIndex
Keyboard: Arrow Left/Right, Home, End.
Accordion
<div data-accordion>
<div data-accordion-item>
<button data-accordion-trigger>Section 1</button>
<div data-accordion-content>Content</div>
</div>
</div>enhance("[data-accordion]", accordion({ multiple: false }));Options: itemSelector, triggerSelector, contentSelector, multiple
Keyboard: Arrow Up/Down, Home, End.
Form
<form data-form action="/api/contact" method="post">
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>enhance("[data-form]", form({
onSuccess: (el, res) => console.log("Sent!", res),
onError: (el, err) => console.error("Failed", err),
}));Animate
<div data-animate class="fade-in">Appears on scroll</div>.fade-in { opacity: 0; transition: opacity 0.4s ease; }
.fade-in.is-visible { opacity: 1; }enhance("[data-animate]", animate({ once: true }));Writing Custom Components
Use defineComponent for scoped DOM access, delegated events, and auto-cleanup:
import { defineComponent, enhance } from "@beardcoder/stitch-js";
const tooltip = defineComponent(
{ position: "top" as "top" | "bottom" },
(ctx) => {
const tip = document.createElement("span");
tip.textContent = ctx.attr("tooltip") ?? "";
tip.className = `tooltip tooltip--${ctx.options.position}`;
ctx.on("mouseenter", () => ctx.el.appendChild(tip));
ctx.on("mouseleave", () => tip.remove());
ctx.onDestroy(() => tip.remove());
},
);
enhance("[data-tooltip]", tooltip());
enhance("[data-tooltip-bottom]", tooltip({ position: "bottom" }));Or use the low-level ComponentFactory type for maximum control:
import type { ComponentFactory } from "@beardcoder/stitch-js";
function myBehavior(): ComponentFactory {
return (el) => {
// attach behavior...
return { destroy() { /* cleanup */ } };
};
}Development
bun install
bun test
bun run build
bun run typecheckRolling Releases (GitHub + npm)
Releases are fully automated via GitHub Actions + Semantic Release.
- Every push to
mainruns verification (test,typecheck,build). - If verification succeeds, Semantic Release creates a GitHub Release and publishes to npm.
Required GitHub Secrets
NPM_TOKEN(npm automation token with publish rights)
Commit Style (required for automatic versioning)
fix:,perf:,refactor:,chore:,docs:,test:,build:,ci:-> patch releasefeat:-> minor releasefeat!:orBREAKING CHANGE:-> major release
License
MIT
