We are pleased to present the 0.9.90 release of hyperscript. This is a significant release that includes a complete internal restructuring, a new reactivity system, many new commands and expressions, and improved error handling.
There is an Upgrade Guide at the bottom of this release note.
The headline feature of this release is a new reactivity system with three features that let you declare relationships between values and have them stay in sync automatically.
Keeps the DOM in sync with values. Each command in a live block becomes an independent
tracked effect that re-runs when its dependencies change:
<button _="on click increment $count">+1</button>
<output _="live put 'Count: ' + $count into me"></output>
when reacts to value changes with side effects:
<div _="when $source changes set $derived to (it * 2)"></div>
<output _="when $derived changes put it into me"></output>
bind keeps two values in sync (two-way binding). On form elements it auto-detects the right
property (value, checked, valueAsNumber):
<input type="checkbox" id="dark-toggle" />
<body _="bind .dark and #dark-toggle's checked">
The reactivity system includes automatic dependency tracking, circular dependency detection, and cleanup when elements are removed from the DOM.
The render command and template language are now built into core — no extension script needed.
Templates use <template> elements with ${} interpolation and #-prefixed control flow:
<template id="user-card">
#for user in users
<div class="card">
<h3>${user.name}</h3>
#if user.bio
<p>${user.bio}</p>
#end
</div>
#end
</template>
<button _="on click
render #user-card with users: userData
put the result into #container">
Load Users
</button>
Interpolated expressions are HTML-escaped by default. Use ${unescaped expr} for raw output.
Open and close dialogs, details elements, popovers, and fullscreen. The command automatically detects the element type and calls the right API:
open #my-dialog -- showModal() for <dialog>
close #my-dialog -- close() for <dialog>
open #my-details -- sets open on <details>
open fullscreen -- fullscreen the entire page
open fullscreen #video -- fullscreen a specific element
close fullscreen -- exit fullscreen
focus and blur set or remove keyboard focus. Default to me if no
target is given:
on click focus #name-input
on submit blur me
empty removes all children from an element:
on click empty #results
swap exchanges the values of two assignable expressions — variables, properties, array
elements, or any combination:
swap x with y
swap arr[0] with arr[2]
swap #a.textContent with #b.textContent
Select the text content of an input or textarea:
on focus select #search-input
Access the browser's built-in dialogs. ask wraps prompt() and places the result in
it. answer wraps alert(), or confirm() when given two choices:
ask "What is your name?"
put it into #greeting
answer "File saved!"
answer "Save changes?" with "Yes" or "No"
if it is "Yes" ...
Text-to-speech via the Web Speech API — a nod to HyperTalk. The command waits for the utterance to finish before continuing:
speak "Hello world"
speak "Quickly now" with rate 2 with pitch 1.5
scroll scrolls elements into view with alignment, offset, and smooth scrolling. Use in to
scroll within a specific container, or scroll by for relative scrolling:
scroll to #target
scroll to the top of #target smoothly
scroll to the bottom of me +50px
scroll to #item in #sidebar smoothly
scroll down by 200px
scroll #panel left by 100px
Pause execution in the browser DevTools. Now built in to core — no hdb extension required:
on click
breakpoint
add .active
Filter, sort, map, split, and join collections with postfix expressions that chain naturally.
it/its refer to the current element:
items where its active sorted by its name mapped to its id
"banana,apple,cherry" split by "," sorted by it joined by ", "
<li/> in #list where it matches .visible
New magic symbols for accessing the system clipboard and current text selection:
put clipboard into #paste-target -- async read, auto-awaited
set clipboard to "copied!" -- sync write
put selection into #selected-text -- window.getSelection().toString()
ResizeObserver as a synthetic event,
matching the pattern of on mutation and on intersection:
on resize put `${detail.width}x${detail.height}` into #size
on resize from #panel put detail.width into me
One-shot event handlers that fire only once:
on first click add .loaded to me then fetch /data
Case-insensitive modifier for comparisons:
if my value contains "hello" ignoring case ...
show <li/> when its textContent contains query ignoring case
Apply commands conditionally per element. After execution, the result contains the matched elements.
Works on add, remove, show, and
hide:
show <li/> in #results when its textContent contains my value
show #no-match when the result is empty
Loops that run the body at least once before checking the condition:
repeat
increment x
until x is 10 end
Chain conversions left to right:
get #myForm as Values | JSONString
get #myForm as Values | FormEncoded
Variables with the ^ prefix are scoped to the element and inherited by all descendants,
ideal for component state without polluting the global scope:
<div _="init set ^count to 0">
<button _="on click increment ^count">+1</button>
<output _="live put ^count into me"></output>
</div>
transition and measure --
the internal pseudopossessive parsing mechanism has been removed; these commands now use the same expression
syntax as everything elseThis release is a complete ESM rewrite of the codebase (45 modules). Element state is now stored on
elt._hyperscript (inspectable in DevTools), aligned with htmx's elt._htmx pattern. The test suite was
migrated to Playwright. See the CHANGELOG for the full list of internal changes and bug fixes.
If you are upgrading from 0.9.14 or earlier, the following breaking changes may require updates to your code.
All extension scripts have been reorganized into a dist/ext/ subdirectory.
Upgrade Step: Search for dist/hdb.js, dist/socket.js, dist/worker.js, dist/eventsource.js,
dist/tailwind.js and replace with dist/ext/hdb.js, dist/ext/socket.js, etc.
The template extension (dist/template.js or dist/ext/template.js) is no longer needed.
The render command is now built into core. Also, the template command prefix has changed
from @ to #:
-- Before -- After
@repeat in items #for x in items
@set x to "hello" #set x to "hello"
@end #end
Upgrade Step: Remove any <script> tag that loads template.js. If your templates use @ command
prefixes, replace them with #. Replace @repeat in Y with #for x in Y.
The transition command previously accepted bare identifiers like width and opacity
as CSS property names. Now that hyperscript has style literals (*width, *opacity),
transition requires them for consistency with the rest of the language. The element keyword prefix for
targeting other elements has also been removed in favor of standard possessive and
of syntax.
-- Before -- After
transition width to 100px transition *width to 100px
transition my opacity to 0 transition my *opacity to 0
transition element #foo width to 100px transition #foo's *width to 100px
transition now also supports of syntax: transition *opacity of #el to 0.
Upgrade Step: Search for transition commands and add * before style property names. Replace
transition element #foo with transition #foo's.
In previous versions, as JSON called JSON.stringify(). It now calls JSON.parse(),
matching the natural reading of "interpret this as JSON". A new as JSONString conversion handles
stringification.
Upgrade Step: Search for as JSON. If it was being used to stringify an object, replace with as JSONString.
If it was parsing a JSON string, no change needed.
The colon-based conversion modifiers have been replaced by the more general pipe operator.
Upgrade Step: Replace as Values:JSON with as Values | JSONString. Replace as Values:Form with
as Values | FormEncoded.
default previously used a truthy check, so default x to 10 would overwrite 0 and
false. It now uses a nullish+empty check: only null, undefined, and "" are considered unset.
Upgrade Step: If you relied on default overwriting falsy values like 0 or false, use an explicit
if instead.
The [@attr] bracket-style attribute access has been deprecated in favor of the
@attr literal.
Upgrade Step: Replace [@attr] with @attr.
The url keyword in go to url X is no longer needed — go to now accepts naked URLs
directly.
The scroll form go to the top of ... continues to work but has been superseded by the dedicated
scroll command.
Upgrade Step: Replace go to url X with go to X. If you like, replace go to the top of #el with
scroll to the top of #el.
The API has been renamed to process() to align with htmx's naming. The old name still works as an alias.
Upgrade Step: Replace _hyperscript.processNode( with _hyperscript.process(.
The async keyword was of limited utility and was confusing due to it having the opposite meaning of JavaScript. It has been removed. If you need to run an asynchrnous operation without blocking you can do so in JavaScript.
Upgrade Step: Remove async from your hyperscript code, moving any necessarily non-blocking async operations out
to JavaScript.
dist/_hyperscript.js is now IIFE (was UMD). Plain <script> tags work unchanged.
Upgrade Step: If you use ES module imports, switch to dist/_hyperscript.esm.js.
Enjoy!