_hyperscript
10 min read

_hyperscript is a small scripting language for the web, designed to be embedded directly in HTML attributes. It is the sister project of htmx — same author (Carson Gross / Big Sky Software), same philosophy: put the code on the thing that does the thing ("locality of behaviour"). BSD 2-Clause, dependency-free, ~38 KB over the wire for the entire language — parser, runtime, async engine, event system.

html
<button _="on click toggle .red on me">Click Me</button>

Where Alpine.js gives you JavaScript in attributes, _hyperscript gives you a different language in attributes. It belongs to the xTalk family descending from HyperTalk, HyperCard's scripting language, so it reads like English. Two practical advantages for something living inside an HTML attribute: it avoids noisy punctuation, and it reads top-to-bottom so behaviour is visible from source.

The project is pre-1.0 and under active development.

Install

html
<script src="https://cdn.jsdelivr.net/npm/hyperscript.org@0.9.93/dist/_hyperscript.min.js"></script>

Also ships as an ES module (_hyperscript.esm.min.js) and on npm as hyperscript.org — the full domain is the package name. import 'hyperscript.org' auto-initialises; there is nothing to call.

Scripts go in the _ attribute (script and data-script also work), or in <script type="text/hyperscript"> blocks for anything shared.

Style

The language is deliberately readable. The word the can be inserted almost anywhere — the parser ignores it:

hyperscript
set innerHTML of #d1 to "hello"
set the innerHTML of the #d1 to "hello" -- identical

Property access has three forms, in rough order of preference: possessives (#output's innerHTML, my innerHTML, its name), of expressions (the children of the closest <ul/> — binds more loosely, so it avoids parentheses), and plain dot notation (fine for method calls and deep access). Comments are --.

Magic symbols

SymbolMeaning
me, my, Ithe element the script is on
it, its, resultresult of the last command
you, your, yourselfcurrent target inside a tell block
event, target, detail, senderthe event and its parts
bodydocument.body
cookiescookie jar (get/set/clear)
clipboardsystem clipboard
selectioncurrently selected text

Scoping

hyperscript
set x to 1 -- local to this handler invocation
set :x to 1 -- element-scoped, persists across handler calls
set $x to 1 -- global (window)
set ^x to 1 -- DOM-scoped: walks up the tree to find/set it

:x is what you use for component state — plain locals reset every time the handler runs, which is a common beginner trap. ^x searches up the DOM for the nearest element defining it, giving shared state for a region of the page without globals.

Async transparency

The most distinctive feature. The runtime resolves Promises internally, so asynchronous code is written linearly — there is no await keyword and none is needed. This effectively decolours functions: every function and handler is async-transparent.

hyperscript
log "Start..."
wait 1s
log "Finish..."

fetch /api/data as JSON -- automatically waits
put it into #result

wait is not just a timer — it waits on events, which pulls event-driven control flow into ordinary linear code:

hyperscript
on click
put 'Started...' into the next <output/>
wait for a continue or 3s
if the result's type is 'continue'
put 'Finished...' into the next <output/>
otherwise
put 'Timed Out...' into the next <output/>
end

Same idea in toggle .red until mouseleave — a command whose duration is bounded by an event.

Features

The top-level constructs in a script.

hyperscript
on click add .active to me
on click from #other-element ...
on keyup[key is 'Escape'] ... -- event filter
on click(clientX, clientY) ... -- destructure event properties
on every click ... -- allow concurrent runs
on first click ... -- once
on click debounced at 300ms ...
on click throttled at 500ms ...
on click queue all ... -- all | first | last (default) | none
on mutation of @class from #target ... -- MutationObserver
on intersection(intersecting) having threshold 0.5 ...
on resize put detail.width into #size ...

Plain on queues handlers and the last one wins; on every runs them concurrently — worth knowing before debugging a handler that seems to be dropped.

hyperscript
def greet(name) -- functions; support catch/finally and bare return
return "Hello, " + name
end

behavior Removable(removeButton) -- reusable bundle of features
on click from removeButton remove me end
end
-- installed with: _="install Removable(removeButton: #btn)"

init add .loaded to me end -- run on load; `init immediately` runs first
set :count to 0 -- feature-level element variable
js function helper() { return 42 } end -- inline JavaScript

Commands

Assignment and DOM insertion — note set assigns, put is DOM-aware:

hyperscript
set #d1's innerHTML to "hello"
set @data-value to "foo" -- attribute
set *color to "red" -- style
default x to 10 -- only if null/undefined/"" — keeps 0 and false

put "hello" into me -- clears children, inserts as HTML
put "hello" before/after me
put "hello" at start/end of me
put item at end of myArray -- push
increment :count by 5

DOM manipulation reads as instructions rather than API calls:

hyperscript
add .active to #target
add {color: red; font-size: 2em} to me -- inline styles
remove @disabled from me
remove me -- delete the element
toggle .active for 2s -- auto-toggles back
toggle .active until mouseout
toggle between .on and .off
take .active from .tabs for me -- the tab-selection idiom, in one line
show/hide me -- also handles <dialog>
focus/blur/select/empty #input
open/close #modal -- dialog, details, popover

add, remove, show and hide take a when clause evaluated per element, and afterwards the result holds the matched elements — which is the whole filter-with-fallback pattern:

hyperscript
on input
hide #no-match
show <li/> in #results when its textContent contains my value ignoring case
show #no-match when the result is empty

Control flow uses end rather than braces; there is no single-line if:

hyperscript
if x > 10 log "big" else log "small" end
repeat for item in items ... end
repeat 3 times / while x < 10 / until x > 10 / forever
for item in items index i ... end
tell #target add .active end -- rebinds `you` to the target

Others: send/trigger custom events, halt (with halt bubbling / halt default variants), throw, call/get, transition ... over 500ms and settle for animation, measure, pick first 3 of arr, scroll to, go to /page, log, make a <div/>, start a view transition, and the HyperTalk holdovers ask (prompt), answer (alert/confirm) and speak (Web Speech, waits for the utterance to finish).

Expressions

CSS selectors are first-class syntax rather than strings passed to querySelector:

hyperscript
#myId .myClass <div.foo/> <div#bar/>
@name *color *computed-color
#${expr} <div.${expr}/> -- interpolated selectors

Operators are words: is/is not, and/or/not, mod, contains, matches, exists, is empty, is a String, plus an ignoring case postfix on comparisons. no x and some x test emptiness.

Collection expressions chain postfix, using it/its for the current element:

hyperscript
items where its active sorted by its name mapped to its id
"banana,apple,cherry" split by "," sorted by it joined by ", "

Conversions use as, pipeable with |:

hyperscript
x as String / Int / Float / Date / JSON / JSONString / HTML / Set / Map / Keys / Unique / Flat
x as Fixed:2
<form/> as Values | FormEncoded

Positional expressions do DOM traversal in English: first <li/> in #list, next <input/> from me, previous <li/> from me within #list, next <li/> with wrapping, closest <form/>, closest @data-id. Times and CSS units are literals: 500ms, 2 seconds, 10px, 2em.

Reactivity

The runtime tracks reads and writes of reactive variables — $x, :x, ^x, attributes, and in-place array mutations. Plain locals are not reactive.

hyperscript
live put 'Count: ' + $count into me -- re-runs when dependencies change

when ($price * $qty) changes -- side effects on change, new value in `it`
if it > 100 add .expensive to me else remove .expensive from me end
end

bind my value to #slider's value -- two-way sync

Use live for derived values and DOM sync, when for side effects, bind for form inputs — and plain on handlers when you know exactly what changes.

Networking

fetch is async-transparent and throws on 4xx/5xx by default (configurable via _hyperscript.config.fetchThrowsOn):

hyperscript
fetch /api/data as JSON
fetch /api/me with headers:{Authorization:`Bearer ${token}`} as JSON
fetch /api/submit with method:"POST", body:(<form/> as FormData)
fetch /api/data as JSON do not throw -- suppress
fetch /api/data as Response -- raw Response, never throws

Server-Sent Events and WebSockets are declarative features from extensions:

hyperscript
eventsource ChatStream from /events
on message put it into #messages
end

socket ChatSocket ws://localhost:8080
on message put it into #chat
end
-- send "hello" to ChatSocket

Extensions

Loaded as separate scripts after the core:

FileAdds
component.jscustom elements with reactive templates, slots, isolated DOM scope
template.jsthe render command — ${} interpolation, #for/#if/#else blocks
hdb.jsin-browser stepping debugger
socket.jsWebSockets, with RPC
worker.jsWeb Workers
eventsource.jsServer-Sent Events
intercept.jsservice worker DSL for caching and offline
tailwind.jsTailwind show/hide strategies

Components are <template component="..."> elements. Each instance gets an isolated DOM scope, ${} expressions re-render reactively on ^var changes, and rendering goes through morphing so focus and handlers survive:

html
<template component="click-counter" _="init set ^count to 0">
<button _="on click increment ^count">+</button>
<span>Clicks: ${^count}</span>
</template>

<click-counter></click-counter>

Custom commands, expressions and features can also be added in plain JavaScript.

Tooling

• log x and breakpoint are built in; hdb.js upgrades breakpoint to a full stepping UI with variable inspection.
• beep! is a pass-through debugging operator — drop it before any expression and it logs the source, value and type of that expression to the console without changing evaluation:
hyperscript
add .highlighted to <p/> in beep! <div.hilight/>
///_ BEEP! The expression (<div.hilight/>) evaluates to: [div.hilight] of type ElementCollection
• A CLI validator catches parse errors across a codebase, extracting hyperscript from attributes and <script type="text/hyperscript"> blocks. Exits 1 on error, so it drops into CI:
sh
npx hyperscript.org --validate src/ --ext .njk,.erb --attr hs,data-hs
It checks syntax only — it does not execute anything.

JavaScript interop

Any JS function can be called directly (call alert('hi')), and js ... end blocks embed inline JavaScript with parameters and return values, exposed globally at top level. Inline JS is also the escape hatch for performance, since the runtime prioritises flexibility over speed, and it is how you ship an implementation across to a worker.

Security

Because hyperscript is interpreted and does not use eval (except for inline js features), a CSP that forbids inline scripting has no effect on it — which is almost certainly not what a security team intends when they set one.

The real control is escaping. Any untrusted content injected into the page must have _, script and data-script attributes filtered, exactly as you would filter <script> tags. As a backstop, disable-scripting (or data-disable-scripting) on an enclosing element prevents hyperscript from executing anywhere inside it:

html
<div data-disable-scripting><%= user_content %></div>

htmx integration

No configuration needed — when htmx is on the page, hyperscript processes scripts on htmx-swapped content (listening for htmx:load and htmx:after:process) and calls htmx.process() on content it inserts itself. Under htmx 4+, the hs-include extension serialises element-scoped variables (:count) into htmx requests as flat form fields, like hx-vals.