Skip to content

Hyperscript

_hyperscript is an event-oriented scripting language inspired by HyperTalk. It is small, easy to read inline in markup, and pairs cleanly with htmx. Hypermedia Components does not require it — the vanilla behaviors in @hypermedia-components/core are the default — but every behavior has a natural _hyperscript equivalent if you prefer to keep the logic next to the markup. And the interactive components emit hc:* events you can react to inline — see Reacting to component events.

Use this page as a translation reference: copy a snippet, paste it into an existing _= attribute (or a <script type="text/hyperscript"> block), and skip the matching installXxx() call.

Add _hyperscript after htmx and before any markup that uses _= attributes.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/assets/hc/hc.css">
<script defer src="https://unpkg.com/htmx.org@2"></script>
<script defer src="https://unpkg.com/hyperscript.org@0.9"></script>
<!-- Only the bits of HC you still want. See "Mixing approaches" below. -->
<script type="module" src="/assets/hc/hc.behaviors.min.js"></script>
</head>
<body>
...
</body>
</html>

_hyperscript self-installs at DOMContentLoaded. There is no configuration step.

The vanilla installXxx behaviors and the _hyperscript snippets below do the same thing — intercept events, talk to native DOM APIs, never wrap fetch(). The choice is ergonomic:

  • Vanilla wins when the same behavior shows up on many elements (one installConfirm() call, every [data-hc-confirm] is handled), when you want JSDoc / TypeScript types on the helper, or when the logic outgrows a one-liner.
  • _hyperscript wins when the logic is local to a single element, when you want the markup to read like prose, or when you cannot ship a bundle that includes hc.behaviors.min.js.

Both compose with htmx the same way: dispatch an event, let htmx listen for it via data-hx-trigger.

You do not have to choose globally. Drop the installXxx you are replacing — keep the others. For example, to replace just the confirm behavior with inline _hyperscript while keeping the vanilla toast region, import the individual installers:

import {
installToast,
installCloseDialog,
installClosePopover,
installRemoteDialog,
} from '@hypermedia-components/core';
installToast();
installCloseDialog();
installClosePopover();
installRemoteDialog();
// installConfirm() intentionally skipped — _hyperscript handles confirm inline.

The @hypermedia-components/core/behaviors auto-init entry installs every default behavior at once. Skip that import when you are picking and choosing, and call the individual installX() functions you want.

Vanilla — driven by installConfirm():

<button
class="hc-button"
data-variant="error"
data-hc-confirm="Delete this item?"
data-hx-delete="/items/123"
data-hx-trigger="hc:confirmed"
data-hx-target="closest tr">
Delete
</button>

_hyperscript — handle the confirmation inline; htmx still listens for hc:confirmed:

<button
class="hc-button"
data-variant="error"
data-hx-delete="/items/123"
data-hx-trigger="hc:confirmed"
data-hx-target="closest tr"
_="on click
if confirm('Delete this item?')
send hc:confirmed to me
end">
Delete
</button>

confirm(...) is the browser primitive — synchronous, blocking, and not styleable. To use the shared <dialog class="hc-confirm-dialog"> markup instead, render it once in the page and target it by id:

<dialog id="confirm-dialog" class="hc-dialog hc-confirm-dialog"
aria-labelledby="confirm-title" aria-describedby="confirm-message">
<header class="hc-dialog__header">
<h2 class="hc-dialog__title" id="confirm-title">Confirm</h2>
</header>
<div class="hc-dialog__body" id="confirm-message">Are you sure?</div>
<footer class="hc-dialog__footer">
<button class="hc-button" type="button"
_="on click call closest <dialog/> then call it.close('cancel')">
Cancel
</button>
<button class="hc-button" type="button" data-variant="primary"
_="on click call closest <dialog/> then call it.close('confirm')">
Confirm
</button>
</footer>
</dialog>
<button
class="hc-button"
data-variant="error"
data-hx-delete="/items/123"
data-hx-trigger="hc:confirmed"
data-hx-target="closest tr"
_="on click
set #confirm-message.textContent to 'Delete this item?'
call #confirm-dialog.showModal()
wait for close from #confirm-dialog
if #confirm-dialog.returnValue is 'confirm'
send hc:confirmed to me
end">
Delete
</button>

The _hyperscript variant gives you the same a11y story (<dialog showModal> handles focus trapping and Escape), but you write the orchestration in the script attribute instead of relying on a shared installed behavior. See the confirm-action recipe for the same example in context.

Vanilla — dispatch on document.body, let installToast() render:

document.body.dispatchEvent(new CustomEvent('hc:toast', {
bubbles: true,
detail: { message: 'Saved', variant: 'success' },
}));

_hyperscript — same dispatch, no helper:

<button
class="hc-button"
_="on click send hc:toast(message:'Saved', variant:'success') to <body/>">
Save
</button>

installToast() still has to be running somewhere to actually render the toast. _hyperscript only replaces the call site, not the listener.

Vanilla — installCloseDialog() watches htmx:afterRequest for any ancestor with data-hc-close-dialog-on-success:

<form
data-hx-post="/items"
data-hx-target="closest dialog"
data-hx-swap="outerHTML"
data-hc-close-dialog-on-success>
...
</form>

_hyperscript — listen on the same event, scoped to this element:

<form
data-hx-post="/items"
data-hx-target="closest dialog"
data-hx-swap="outerHTML"
_="on htmx:afterRequest
if event.detail.successful
call closest <dialog/> then call it.close()
end">
...
</form>
<form
data-hx-get="/items"
data-hx-target="#results"
_="on htmx:afterRequest
if event.detail.successful
call closest <[popover]/> then call it.hidePopover()
end">
...
</form>

Remote dialog (open a server-rendered dialog after swap)

Section titled “Remote dialog (open a server-rendered dialog after swap)”

Vanilla — installRemoteDialog() watches htmx:afterSwap on any [data-hc-remote-dialog-root]:

<div id="dialog-root" data-hc-remote-dialog-root></div>

_hyperscript — same idea, inline:

<div
id="dialog-root"
_="on htmx:afterSwap
set dlg to my.querySelector('dialog')
if dlg and not dlg.open then dlg.showModal()">
</div>

The interactive components (menu, combobox, command, calendar, toggle group, input OTP, splitter, tabs, …) keep their internals in the vanilla behaviors — that is where the WAI-ARIA keyboard model, focus management, and roving tabindex live, tested once and consistent everywhere. What they expose to you is a small set of bubbling hc:* events. That seam is fully declarative: handle it with _="on hc:…" in _hyperscript, exactly as you would with htmx’s data-hx-trigger.

ComponentEventevent.detail
Menu / Context menuhc:menuselect{ item, menu, checked?, contextTarget? }
Comboboxhc:comboboxselect{ value, label, option, input }
Multicomboboxhc:multicomboboxchange{ values, added, removed, input }
Commandhc:commandselect{ item, value, command }
Calendarhc:calendarchange{ value, date }
Input OTPhc:otpchange · hc:otpcomplete{ value, input }
Splitterhc:splitterchange{ value, orientation }
Toggle grouphc:togglegroupchange{ type, value? , values?, item, pressed? }
Tabshc:tabactivated(dispatched on the activated panel)

These bubble, so you can listen on the component or any ancestor.

Show the chosen calendar date:

<div class="hc-calendar" data-value="2026-05-15" aria-label="Pick a date"
_="on hc:calendarchange put event.detail.value into #picked"></div>
<output id="picked"></output>

Auto-submit when the OTP fills up (no JS, no hx-trigger needed):

<form data-hx-post="/verify">
<div class="hc-inputotp" data-length="6"
_="on hc:otpcomplete call closest <form/> then call it.requestSubmit()">
<input class="hc-inputotp__input" type="text" name="code" aria-label="Code">
</div>
</form>

Run a command-palette action:

<div class="hc-command"
_="on hc:commandselect
if event.detail.value is 'home' then go to url '/'
else if event.detail.value is 'new' then go to url '/new/'">
...
</div>

Persist the splitter position across visits:

<div class="hc-splitter" data-orientation="horizontal"
_="on load
if localStorage.split then set @data-value to localStorage.split
on hc:splitterchange
set localStorage.split to event.detail.value">
...
</div>

React to a toggle-group / menu choice:

<div class="hc-toggle-group" role="radiogroup" data-type="single" aria-label="View"
_="on hc:togglegroupchange add .is-{event.detail.value} to #grid">
...
</div>
<div class="hc-menu" id="row-menu" popover role="menu"
_="on hc:menuselect call handleRow(event.detail.item.dataset.action)">
...
</div>

Because these are ordinary DOM events, the same handler works in htmx — swap _="on hc:calendarchange …" for data-hx-trigger="hc:calendarchange" when the reaction is a server request rather than a local DOM change. Use _hyperscript for the local glue, htmx for the network call; the component does not care which is listening.

  • You ship a bundle anyway. If your build already produces a modules bundle, the vanilla behaviors compose with it for free. Pulling in _hyperscript for half a kilobyte of orchestration is not a great tradeoff.
  • The behavior is shared across many elements. A single installConfirm() covers every [data-hc-confirm] on the page. Inlining the same _hyperscript on every button is repetition.
  • You want types or unit tests. The vanilla helpers expose JSDoc and ship with .d.ts. _hyperscript is a string in an attribute; type-aware tooling will not look inside it.