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 experimental reactivity system, a rewritten templating system, DOM morph support an experimental components mechanism, 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 <script type="text/hyperscript-template"> elements with ${} interpolation and #-prefixed control flow:
<script type="text/hyperscript-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
</script>
<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.
The morph command by @Latent22 brings DOM morphing to hyperscript - powered by
idiomorph, it intelligently patches the DOM while preserving
focus, scroll position, and form state:
morph #container with newHtml
morph me with responseHtml
A new components system (component) lets you define custom elements with reactive templates, slots,
and scope isolation - all declared in a single <script type="text/hyperscript-template">:
<script type="text/hyperscript-template" component="my-counter">
<button _="on click increment ^count">+1</button>
<output _="live put ^count into me"></output>
</script>
<my-counter></my-counter>
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>
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 (or its alias clear) clears an element's content. It's smart about what it
clears - inputs get their values reset, checkboxes get unchecked, selects get deselected, forms get all
their fields cleared, and regular elements get their children removed. It also works on arrays, sets,
and maps:
on click empty #results -- remove children
on click clear #search-input -- clear input value
on click empty myArray -- splice the array
reset restores a form or input to its default value (the value it had when the page loaded):
on click reset <form/>
on click reset #name-input
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
The toggle command now supports cycling any writable value through a list of values
with between. Each toggle advances to the next value, wrapping around:
-- cycle a style property between specific values
toggle *display of #panel between 'none' and 'flex'
toggle *opacity of me between '0', '0.5' and '1'
-- cycle a variable through values
toggle $mode between 'edit' and 'preview'
toggle $theme between 'light', 'dark' and 'auto'
This works with any assignable expression - variables, properties, style refs - and supports any number of values, not just two.
The take command gains two features:
giving keyword -- an alternative to with that reads more naturally after the from clause:
take @aria-selected='true' from <[role=tab]/> in me giving 'false' for tab
Class swap -- take now supports a replacement class via with or giving, so the
from elements get one class and the for target gets the other:
take .selected from .opt giving .unselected for the closest <.opt/> to the target
The remove command now works on object properties and array indices:
remove :arr[1] -- splices index 1 out of the array
remove :obj.field -- deletes the property
remove field of :obj -- same, using the `of` form
For arrays, remove uses splice (indices shift). For objects, it uses delete.
If the value at the expression is a DOM node, remove falls through to DOM detachment
as before.
A new built-in hide/show strategy uses the native hidden attribute:
hide me with hidden
show me with hidden
Set it as the default for your whole app:
_hyperscript.config.defaultHideShowStrategy = "hidden"
Filter, sort, map, split, and join collections with postfix expressions that chain naturally.
it/its refer to the current element. In a for loop, the where
clause can also use the loop variable name directly:
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
for x in items where x.score > 10 ...
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
A new --validate flag lets you check all hyperscript in your project for syntax errors from the command
line, useful for CI and for catching issues during upgrades:
npx hyperscript.org --validate
npx hyperscript.org --validate src/ templates/
It scans .html files for _="..." attributes and <script type="text/hyperscript"> blocks, parses them,
and reports errors with file, line, and column numbers. See the documentation
for the full set of options.
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.
This release includes a CLI validation tool. Run it before and after upgrading to catch any syntax errors introduced by the breaking changes below:
npx hyperscript.org --validate
This scans your .html and ._hs files, parses all hyperscript, and reports errors with
file, line, and column numbers. See the full documentation for
options like targeting specific directories, adding file extensions, and CI integration.
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.
/* */ comments removedJavaScript-style /* ... */ block comments are no longer supported. Use -- or //
line comments instead (both were already supported). This change enables patterns
like /api/* to work correctly in expressions and route patterns.
Upgrade Step: Replace any /* ... */ comments with -- or // line comments.
fetch throws on non-2xx responsesThe fetch command now throws on 4xx/5xx responses by default, matching the
behavior of most modern HTTP libraries. This surfaces errors that previously
passed through silently.
Upgrade Step: If you were relying on 404/500 responses passing through, either:
do not throw to the fetch command: fetch /api as JSON do not throwas Response to get the raw Response: fetch /api as Responsecatch block to handle errors_hyperscript.config.fetchThrowsOn = [] to restore old behavior globallyEnjoy!