hyperscript 0.9.90 Release

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.

Reactivity

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.

live

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 ... changes

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

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.

Templates in Core

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.

Morph

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

Components

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>

DOM-Scoped Variables

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>

New Commands

open / close

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 / blur

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 / clear

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

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

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

Select the text content of an input or textarea:

on focus select #search-input

ask / answer

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" ...

speak

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

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

breakpoint

Pause execution in the browser DevTools. Now built in to core - no hdb extension required:

on click
  breakpoint
  add .active

toggle between values

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.

take enhancements

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

remove properties and array indices

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.

hidden hide/show strategy

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"

New Expressions & Syntax

Collection Expressions

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 ...

clipboard and selection

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()

on resize

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

on first click

One-shot event handlers that fire only once:

on first click add .loaded to me then fetch /data

ignoring case

Case-insensitive modifier for comparisons:

if my value contains "hello" ignoring case ...
show <li/> when its textContent contains query ignoring case

when clause on add, remove, show, hide

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

Bottom-tested loops

Loops that run the body at least once before checking the condition:

repeat
  increment x
until x is 10 end

Pipe operator

Chain conversions left to right:

get #myForm as Values | JSONString
get #myForm as Values | FormEncoded

CLI Validation Tool

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.

Parser Improvements

Internal

This 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.


Upgrade Guide

If you are upgrading from 0.9.14 or earlier, the following breaking changes may require updates to your code.

Validate your codebase

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.

1. Extension paths moved

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.

2. Templates moved into core

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.

3. transition requires * style refs

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.

4. as JSON is now parse, not stringify

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.

5. Values:JSON and Values:Form removed

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.

6. default uses nullish check

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.

7. [@ syntax deprecated

The [@attr] bracket-style attribute access has been deprecated in favor of the @attr literal.

Upgrade Step: Replace [@attr] with @attr.

8. go to url and scroll form deprecated

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.

9. processNode() deprecated

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(.

10. async keyword removed

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.

11. Bundle format changed

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.

12. Multi-line /* */ comments removed

JavaScript-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.

13. fetch throws on non-2xx responses

The 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:

  • Add do not throw to the fetch command: fetch /api as JSON do not throw
  • Use as Response to get the raw Response: fetch /api as Response
  • Wrap in a catch block to handle errors
  • Set _hyperscript.config.fetchThrowsOn = [] to restore old behavior globally

Upgrade Music

Enjoy!