batsignal.js is an alternative to htmx and datastar that provides many of the same features, but focuses on plug-and-play interactions instead of requiring x- or data- attributes for common cases. Pairing batsignal.js with pico.css makes HTML clean and easy to work with.
This project is especially suitable for backend developers who do not want to maintain a large full-stack Node.js framework, but still want to add interactivity to server-rendered pages without writing much JavaScript. Full-stack and frontend developers can also benefit from batsignal.js's simplicity and hackability, adapting it to their needs without extra bloat.
Place the selected scripts immediately before the closing </body> tag.
<!-- Idiomorph is a required dependency -->
<script src="https://unpkg.com/idiomorph@0.7.4/dist/idiomorph.min.js"></script>
<!-- Development: main can contain breaking changes -->
<script src="https://cdn.jsdelivr.net/gh/jhthorsen/batsignal.js@main/batsignal.js"></script>
<!-- Or pin a revision until versioned releases are available:
<script src="https://cdn.jsdelivr.net/gh/jhthorsen/batsignal.js@be29cca/batsignal.js"></script>
-->Idiomorph is a JavaScript library for morphing one DOM tree to another. It makes replacing nodes slower, but it makes the user experience much better: For example, text selection and focused input are remembered.
With batsignal.js loaded, this button is interactive and the same-origin link is fetched without a full reload; an HTML response patches the current document:
<button on:load on:click="alert('Hello')">Hello</button>
<a href="/user/profile">View profile</a>Add a plugin as an inert <script> containing an object expression. The script
element itself is the plugin. Batsignal evaluates each script once, calls optional
setup(script, C) once when it first discovers the plugin, passing the plugin
<script> element and the shared helper registry. Plugins can add helpers to
C in setup(); batsignal includes those helpers in the ctx passed to
plugin init() hooks and attribute handlers. It calls optional init(el, ctx) for each newly initialized element with
on:load, before binding that element's on:* handlers. ctx has the same
helpers available to attribute handlers. Inside both hooks, this refers to
the plugin object.
<script type="text/plain" data-batsignal-plugin="widget">
({
setup(script) {
console.log('Loaded plugin:', script.dataset.batsignalPlugin)
},
init(el, ctx) {
if (el.matches('[data-widget]')) ctx.listen(el, 'click', handleWidgetClick)
},
})
</script>Either method may be omitted. init also runs for new elements added by HTML
patches:
Place plugin scripts before batsignal to run them during the initial page
initialization. Batsignal has no public init method. A plugin script added
later is picked up the next time Batsignal initializes elements, such as after
an HTML patch. Each script tag is an independent plugin; its
data-batsignal-plugin value is a label. Plugins can add shared helpers to
C from setup():
<script type="text/plain" data-batsignal-plugin="clipboard">
({
setup(script, C) {
C.copy = text => navigator.clipboard.writeText(text)
},
})
</script>
<button on:load on:click="ctx.copy('Copied text')">Copy</button>The added helpers are available to every plugin init() hook and attribute
handler. If plugins assign the same helper name, the later setup assignment
replaces the earlier one. Core context helpers such as fetch and listen
take precedence over plugin helpers.
Removing a script tag means it won't run for elements initialized afterward.
Listen for events with the on:<event> syntax. In addition to standard events such as click and submit, batsignal.js provides the special events listed below.
An element must have an on:load attribute to be initialized. This applies to on:value and every other on:* handler too. A blank on:load is enough:
<button on:load on:click="alert('Hello')">Hello</button>on:load runs once for each DOM node when batsignal initializes it. It also runs for new elements after a response patches the page, but not again when Idiomorph retains an existing element during a morph swap.
on:destroy runs immediately before batsignal removes or replaces the element during an HTML patch. Use it to clean up resources that are not managed by batsignal, such as timers or third-party widgets. It does not run when other code removes the element from the DOM.
<div on:load="el.timer = setInterval(refresh, 1000)" on:destroy="clearInterval(el.timer)">
Refreshing…
</div>Once initialized, on:<event> adds an event listener to the element. The handler has el (the element) and evt (the event) in scope.
<input on:load on:input="console.log(el.value)">
<button on:load on:click="el.disabled = true">Disable me</button>on:value runs immediately, then on input for text inputs and textareas, or on change for selects, checkboxes, and radio buttons. It also listens for a custom value event on the element. A defined event.detail is assigned to el.value before the handler runs.
<input on:load on:value="store.name = el.value">
<script>
window.store = new Proxy({}, {
set(object, key, value) {
document.querySelector('#name').textContent = value;
return Reflect.set(object, key, value);
},
});
</script>
<output id="name"></output>Attribute handlers receive el (the element), evt (the event), and ctx.
ctx contains helpers bound to the element:
| Helper | Behavior |
|---|---|
ctx.fetch(url, options) |
Fetches url for el and handles the response as described below. |
ctx.listen(target, name, callback, options) |
Adds a listener using the element lifecycle signal by default. |
ctx.dispatch(target, name, detail, options) |
Dispatches a CustomEvent on target. |
ctx.init(root) |
Initializes root and its descendants that have on:load. |
ctx.signal |
Aborts when batsignal removes or replaces el. |
For example:
<button on:load on:click="ctx.dispatch(document, 'saved', 'Done')">
Save
</button>
<output on:load="ctx.listen(document, 'saved', evt => {el.textContent = evt.detail})"></output>
<button on:load on:click="$('.status').textContent = 'Updated'">
<span class="status">Waiting</span>
</button>Use ctx.init() to initialize elements created in JavaScript. Append the root
before calling it so on:load handlers run with the element in its final DOM
context:
const child = document.createElement('button')
child.setAttribute('on:load', "el.textContent = 'Ready'")
document.body.append(child)
ctx.init(child)$() is exposed on window (when that name is not already in use) and works
like document.querySelector():
<button on:load on:click="$('#status').textContent = 'Updated'">Update</button>
<output id="status"></output>Links and forms targeting the same origin are intercepted. A form without an
action submits to the current page, as it would without batsignal.js.
Cross-origin forms use normal browser submission. Links use GET; forms use
their declared method. data-history controls the history update:
<a href="/account">Push a history entry</a>
<a href="/account" data-history="replaceState">Replace the current entry</a>
<a href="/account" data-history="none">Do not update history</a>Submitting controls can override a form's action, method, or enctype
with the standard formaction, formmethod, and formenctype attributes.
Forms or submitting controls with a target use normal browser submission.
Forms with method="dialog" retain their native dialog behavior.
Modified or non-primary link clicks, links or forms with a target, links with
a download attribute, and cross-origin links use normal browser navigation.
Requests run concurrently by default. Set navigation: true to abort an
earlier pending navigation request before starting a browser-style navigation:
fetch('/account', {navigation: true}). Response handling depends on
Content-Type:
| Content-Type | Event |
|---|---|
text/html |
sse-patch-elements with {data, url} |
a type containing json |
sse-message with {data, url}; data is response text, not parsed JSON |
text/event-stream (parameters allowed) |
sse-<event> with {data, url} for each SSE message, or sse-message when no event is specified |
| anything else | sse-unknown with {response, url} |
SSE connections reconnect after a read failure, but not after a graceful stream
close, matching EventSource. The default delay is 3000 ms; a server-sent
retry: field replaces it. A received id: is sent as Last-Event-ID on the
next connection.
fetch() dispatches a bubbling fetch event on the target before making the
request with {options, headers, url}, after receiving a response with
{response}, and on an error with {error, options, url}. To retry a safe
request, add an on:fetch handler to the element that started it. This example
retries a failed request at most three times, with a three-second delay:
<a href="/account" on:load on:fetch="
if (evt.detail.response) { el.retryCount = 0; return }
if (!evt.detail.error || evt.detail.error.name == 'AbortError' || !el.isConnected) return
if ((el.retryCount ?? 0) >= 3) return
el.retryCount = (el.retryCount ?? 0) + 1
setTimeout(() => el.isConnected && ctx.fetch(evt.detail.url, evt.detail.options), 3000)
">
View account
</a>A full HTML document replaces the body (unless the response contains data-swap elements). A fragment updates elements by id, or can explicitly target an element with data-swap:
<!-- Returned by the server -->
<div data-swap="replaceWith:#messages">
<p>New message</p>
</div>
<!-- Also supported: replaceWith:#messages and morph:#messages -->When a swap target does not exist, or its requested mode is unsupported, batsignal logs a warning and ignores that response element.
Fragments are parsed through a <template>, so partials can contain elements
that require a specific parent context, such as a table row:
<tr data-swap="append:#orders tbody">
<td>New order</td>
</tr>morph uses the required window.Idiomorph dependency. The following modes control a response element without inserting it:
<!-- Discard the returned element without changing the current DOM. -->
<div data-swap="ignore"></div>
<!-- Retain the current element with the same id. -->
<div id="editor" data-swap="keep"></div>
<!-- Remove the selected current element. -->
<template data-swap="remove:#messages"></template>For a full-document response, keep moves the current element into the incoming document. For a fragment response, it discards the incoming element.
To add headers to every batsignal request, the meta tag content is inserted inside an object literal. Use object entries, without outer braces:
<meta name="fetch-headers" content='"X-Request-ID": "example"'>The content is evaluated as JavaScript. Only serve trusted documents.
This repository's index.html is a client-side demo and can be served directly from GitHub Pages. Its overall functionality test exercises links, GET forms, and HTML partial patches using static files in tests/. Enable Pages from the repository root in Settings → Pages.
Attribute values and fetch-headers are compiled with new Function(). Never put untrusted input into either location. Batsignal does not sanitize HTML received from the server; sanitize user-generated content before sending it in an HTML response.
MIT. See LICENSE.