Wisp is in pre-release, self-hosted since GitHub suspended us. Install it, or see the changelog.

Wisp

Browser Code

Pages work without JavaScript. A script and directives in the same .wisp file add behavior. There is no bundler. ${…} is Rust (server). A quoted directive value and {:…} are JavaScript (browser). Inside {:…}, a directive value and a <script>, ${ is JavaScript's template literal, not a server hole: {:`Hi ${name}`} works. Use the server's ${expr} outside them.

<button on:click="count++">Clicked {:count} times</button>

An undeclared name a handler counts or toggles (n++, open = !open) starts at 0 or false. For another start, write let count = 5; in the --- block.

State in the --- Block

Example: Reactivity.

A literal let in the --- block (a number, true/false, None, a string, or [..]/vec![..] of those, alone on its line) becomes browser state at no per-request cost. {:count} and directives read it. A computed value, or one the server also renders, stays a Rust let and is sent with the page.

A let sharing its line with other code, or an integer literal over 15 digits, stays Rust. A moved number is no longer checked as a Rust type.

The Script

A bare <script> (no attributes, one per file) is for real browser logic: the DOM, $effect, lifecycle, imports, $props. It works in pages, layouts and components and runs once per place that shows the file. <script type|src> stays plain HTML.

  • Top-level lets are state. Assigning one redraws, and so does changing an object, array, Map or Set in it.
  • import lines move to the module head (import c from 'https://esm.sh/canvas-confetti').
  • npm: wisp npm add canvas-confetti[@1.2.3|tag] pins it in package.json (wisp npm remove x, no Node). Then import c from 'canvas-confetti' (also 'pkg/sub', '@scope/pkg') works in scripts and src/lib. A package missing from package.json, or a range (^1.0), is a build error.

Runes

A write redraws only the bindings that read what changed. The script runs once.

<p>{:done} of {:todos.length} done</p>
<script lang="ts">
  let todos = $state([{ id: 1, text: 'Tea', done: false }])
  let done = $derived(todos.filter((t) => t.done).length)
  $effect(() => { document.title = `${done} done` })
</script>
RuneMeaning
let x = $state(v)Deep state (a plain let x = v is the same).
$state.raw(v) / $state.snapshot(x)Changes only when assigned / plain copy.
$derived(expr), $derived.by(fn)Recomputed when read; assigning is a build error.
$effect(fn)After the DOM is drawn and when what it read changes; may return a cleanup.
$effect.pre(fn) / $effect.root(fn) / $effect.tracking()Before the DOM is drawn / effects end with the function it returns / whether code tracks what it reads.
let { a, b = 1, c: d, ...rest } = $props()Component props; needs no {@props}. Rename keyword props (class: klass).
$bindable(default)A prop a parent may bind:.
$inspect(a, b)Logs on change (.with(f)); gone in release.
$props.id()A per-instance id, for for and aria-*.
$cartStore cart's .value, tracked; $cart = x sets it.

untrack(fn) reads without tracking. getAbortSignal() in an $effect or $derived returns a signal that aborts when it runs again or ends (outside one it throws). Call it before the first await. A class's $state/$derived fields make its instances state. For a Date or other instance, assign again (d = d). A misplaced rune is a build error.

TypeScript

<script lang="ts">, src/lib/*.ts ('$lib/x') and +page.ts. Wisp strips types in place; there is no compiler. src/lib/x.ts is served as /_app/c/lib/x.js. TypeScript that produces code is a build error that says what to write:

Not allowedWrite
enum Color { Red }const Color = { Red: 'red' } as const
namespacea module
constructor(private x: number)x: number; constructor(x: number) { this.x = x }
import fs = require('fs')import fs from 'fs'

wisp check --types type-checks with the app's TypeScript (npm install -D typescript, or WISP_TSC naming a tsc). Without one it skips and says so.

[package.metadata.wisp] in Cargo.toml (Config): script = "ts" requires lang="ts" on every <script> and refuses <script src> of a local file, src/**/*.js and +page.js. typecheck = true runs the check in wisp build and wisp check, and fails when tsc or Node is missing. wisp new asks for both (--ts, --typecheck).

Environment Variables

env.PUBLIC_NAME in browser code is written in at build, from the build's environment then .env (wisp dev rebuilds on change). Only PUBLIC_ names reach the browser (env.DATABASE_URL is a build error), and an unset one is a build error too, so set it even if empty (PUBLIC_FLAG=). On the server use wisp::env("K").

Translations

t('cart.items', n) or t('hi', { name, count: n }) in a script or directive, with no import. The build checks keys. src/lib code cannot call t. Message files: Tooling, images and translations.

Directives

SyntaxMeaning
on:click="count++"Handler; a bare name (on:click="press") is called with the event.
bind:value="q" / bind:checked="done"Two-way. An undeclared name becomes state, so live search needs no <script>.
bind:group="size"Radios (value) and checkboxes (array) sharing a name.
bind:files bind:open bind:innerHTML bind:currentTime bind:paused…Any property; sizes too (clientWidth/Height, offsetWidth/Height, contentRect).
bind:this="el"Element into el.
:hidden="!open"Live attribute; false, null, undefined remove it.
:text="name"Live text.
class:open="isOpen"Toggle a class; class:open alone reads open.
style:--x="x"Style property; style:color alone reads color. Server form: style:color=${c} (Template syntax).
class={:['card', { on }]}Names from strings, arrays, truthy object keys. A class a script added stays.
style={:{ color, fontSize: '2em' }}Properties from an object. Values with ;, { or } are dropped. A string style={:s}: the first paint keeps only the declarations that style=${s} keeps, and a browser update leaves it to the browser's parser.
{:...attrs}Each key an attribute (an on… function a listener). Server: ${...attrs}.
transition:fadefade slide scale fly blur; options transition:fly="{ y: 20 }".
in:fly / out:fadeOnly in / only out.
transition:spinYour spin(el, options, { direction }) returning { duration, delay, easing, css } or { tick }. A duration that is no number (NaN, a string) is 0.
on:introstart, on:introend, on:outrostart, on:outroendEvents a transition sends to its element. An end event does not come when a newer play cancels it, or when frames stop (for example in a hidden tab). Only an element with an on:intro* or on:outro* handler gets these events: addEventListener in a script or a use: action does not get them.
use:tip="'Hello'"Calls tip(el, 'Hello') and its update on change; may return a cleanup or { update, destroy }.
use:portal="'#modal'"Move the element there (bare: <body>).
use:outside="() => open = false"Calls it at a press outside the element.
use:inview="(v) => seen = v"v is true when the element comes into view and false when it leaves.
use:shortcut="'mod+k'"The keys click the element (a field gets the focus). Modifiers: ctrl shift alt meta. mod is Cmd on a Mac and Ctrl elsewhere.
use:modal="open"A <dialog> shown as a modal while open is true.
use:preloadFetch the link ahead once in view; not with data saver or data-wisp-preload="off".
use:keepscrollBack and forward restore this element's scroll.
{@attach f}Calls f(el) in an effect once the element is in the page. It calls it again when what it reads changes. The function it returns runs before that and at the end.
animate:flipIn a keyed {:#each}: reordered items slide to their new place. Options { duration, delay, easing }. See Client blocks.

Attachments

{@attach f} goes in the start tag of an element. On a component tag (<Card {@attach f} />) it is a build error (W0105): put it on an element inside the component. f(el) runs in an effect, so it runs again when the state it reads changes. Its cleanup runs first. A value that is not a function attaches nothing. When f returns a value that is not a function, it has no cleanup. Wisp ignores the promise of an async function, so it is no cleanup either. fromAction wraps a use: action: its update gets the new argument.

<script>
  let text = $state('Hello');
  const tip = (el) => {
    el.title = text;
    return () => (el.title = '');
  };
  const wide = new MediaQuery('min-width: 600px');
</script>

<button {@attach tip}>Hover</button>
<div {@attach fromAction(tooltip, () => text)}>x</div>
<p>{:wide.current ? 'wide' : 'narrow'} {:prefersReducedMotion.current ? 'still' : ''}</p>

new MediaQuery(query) makes current, which is true while the query matches. It is state, so what reads it runs again when it changes. A query with no ( and no media type (all, print, screen) gets parentheses: min-width: 600px is (min-width: 600px). It listens for changes only while an effect or block reads current. A read outside an effect or block returns the match now and does not update. A read inside derived() keeps the listener until that derived value runs again. prefersReducedMotion is one for (prefers-reduced-motion: reduce). Wisp loads this code only on pages that use it. Wisp finds MediaQuery, prefersReducedMotion and fromAction only in the script of a .wisp file or in its template. A .js file of src/lib does not get them.

A page's component keeps its local state across addresses of the same route pattern (/blog/a to /blog/b). To start fresh, key it: {:#key $page.url.pathname}<Post />{:/key} (import { page } from 'wisp').

Event Modifiers

  • .prevent .stop .once .self .capture .passive
  • .window, .document, .outside, .debounce[.300ms] (default 250 ms).
  • A key (.enter, .a) works only on keydown/keyup/keypress; on another event it is a build error.
  • Examples: on:input.debounce.300ms="search()", on:keydown.ctrl.s.prevent.window="save".
  • Keys: .enter .escape .space .tab .backspace .delete .up .down .left .right .home .end .pageup .pagedown, a letter or digit.
  • .ctrl .shift .alt .meta.

An unknown modifier is a build error.

Is This Page Useful?

View This Page's Source

Type to search every docs page and heading.