Skip to content

Client scripts and behaviours in vzk

Use this guide when a vzk site needs something to happen in the browser: a menu that opens, a gallery, a tab set. A file’s own <script> holds the code, and five attributes wire its markup to it.

Small things need no script: a <details>, a <dialog> and a popover open and close with no code. Reach for a script when the markup and the stylesheet cannot express the behaviour.

A file may end with one <script>. It holds one Stimulus controller, written export default class extends Controller. A gallery, as components/Gallery.vzk:

<script server>
const { photos, step = 1 } = props
</script>
<section class="gallery" values={{ step }}>
<img :for={photo in photos} ref="slide" src="/photos/{photo.file}" alt={photo.alt}>
<button on:click="next">Next</button>
</section>
<script>
export default class extends Controller {
index = 0
next() {
this.index = (this.index + this.stepValue) % this.slideTargets.length
this.slideTargets.forEach((slide, at) => { slide.hidden = at !== this.index })
}
}
</script>
Written Means
ref="slide" the controller reads the element as this.slideTarget, and all of them as this.slideTargets
on:click="next" a click calls the controller’s next()
on:keydown.esc="close" only the Escape key
on:resize@window="fit" the event is heard on the window
on:click="close:prevent" an option after the method, such as prevent, stop or once
values={{ step }} a value the controller reads as this.stepValue, on a component’s root element
use="disclosure" the element runs a controller written elsewhere, by its name (below)
load="visible" the element’s controllers are fetched and connected only once it scrolls into view (below)
  • Write no static targets and no static values: both are made from the ref and the values of the file’s markup. Controller needs no import.
  • use, ref and on: take literal text, never a value. A command in braces (below) is the one thing on: takes that is not a method’s name.
  • The five are written on an element, never on a component’s tag: put the attribute on an element in the component’s own file.
  • A component’s controller runs on its root element, so a component with a script has one root element, and each use of it is a controller of its own. The controller of a page or a layout runs on the body.
  • The markup and the script are checked against each other on every write: on:click="next" needs a next() on the class, this.slideTargets needs a ref="slide", and a value the script reads needs to be given.
  • onclick and any other attribute that holds code is refused, and so is a <script> other than the file’s own two.
  • eval, new Function and document.write are refused.

Start in connect() only what disconnect() stops. Read an element through a ref, not document.querySelector, and write a value into the page with textContent, never innerHTML: each of those is a warning otherwise.

Pages change without a full load, so a controller’s connect() and disconnect() are where work for one page starts and ends.

An event can run one command, or a list that runs in order, with no script at all:

<button on:click={toggle("#menu")}>Menu</button>
<div id="menu" hidden>Links</div>
<button on:click={[show("#menu"), addClass("panel", "is-open")]}>Open</button>
<div id="menu" ref="panel" hidden>Links</div>

The commands are show, hide, toggle, addClass, removeClass, toggleClass, setAttribute, toggleAttribute, focus, scrollTo and dispatch. The first argument names the target: #id finds one element on the page, and a plain name finds a ref written once in the same file. show, hide and toggle set hidden; the class commands take one class; setAttribute and toggleAttribute may change title, role, tabindex, hidden, inert, data-state, data-mode, data-active and aria-* and nothing else; dispatch sends a namespaced event such as menu:opened. Every argument is literal: no value from a row or a visitor chooses a command, and a command cannot change an address, a handler or a controller’s wiring.

A command hears a plain event on its own element. A key, a modifier or an event heard on the window belongs to a controller’s method, and an on: holds methods or commands, never both. A target the compiler cannot find is an error; one that is missing when the page runs is skipped.

An element can wait for its controllers until they are needed, so a gallery below the fold costs a visitor nothing until it is seen:

<div load="visible" use="carousel">...</div>
Written Means
load="visible" fetched and connected when the element scrolls into view
load="idle" when the browser has a moment to spare
load="interaction" on hover or the first click, key or focus, which is replayed once the controller is connected
load="eager" at once, as an element with no load

It is written on the element that runs the controllers (a component’s root element, or the element that says use), and connect() runs when the element wakes. An element with no load connects as the page arrives.

use="disclosure" asks for the platform’s own menu, the one that opens and closes a panel. The markup says only which element is which, and the behaviour writes what each part carries (the role, hidden, the events):

<script server>
const { links } = props
</script>
<div class="md:hidden" use="disclosure">
<div class="py-3xs" ref="disclosure.control">
<p class="eyebrow text-muted">Menu</p>
</div>
<div class="fixed inset-0 z-10 bg-ink/3" ref="disclosure.backdrop"></div>
<div class="absolute inset-x-0 top-full z-20 px-gutter py-xl bg-surface border-b border-ink/25" ref="disclosure.panel">
<div class="flex flex-col gap-sm">
<a :for={link in links} href={link.href} class="link-quiet">{link.label}</a>
</div>
</div>
</div>

Open and closed is the hidden attribute, never a class. describe_vocabulary topic=behaviors lists each behaviour and its parts: a part it names disclosure-control is written ref="disclosure.control" here, and the attributes it lists are not copied into a .vzk file. A part or a method the behaviour does not have is an error.

A controller several files share is scripts/controllers/<name>_controller.js, asked for as use="<name>". An underscore in the file’s name is a - in the controller’s: scripts/controllers/tab_list_controller.js is use="tab-list". Such a file is a Stimulus controller written whole, with its own import:

import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.element.classList.add("seen")
this.observer.disconnect()
}
})
this.observer.observe(this.element)
}
disconnect() {
this.observer.disconnect()
}
}

For a controller named carousel, ref="carousel.slide" names one of its targets, on:click="carousel.next" calls its method, and values.carousel={{ seconds }} on the element that says use="carousel" gives it a value. A controller a file names and nothing provides is an error.

A client script imports from @hotwired/stimulus, from a key of the site’s importmap.json, or from a file under the site’s scripts/, asked for by its path without scripts/ and .js: scripts/lib/swipe.js is import swipe from "lib/swipe". Any other import is an error.

importmap.json pins a package by a bare specifier to an https:// address. Pin an exact version, and name each host a pin fetches code from to the owner before merging: it is somebody else’s server running code on the site.

Two site settings change how one page gives way to the next, and both are off unless set: page_fades and morph_refreshes (describe_vocabulary topic=meta). A page says its own in its server script, and that wins either way: export const fades = false, export const morph = true.

  • page_fades fades each page into the next. It asks nothing of a script.
  • morph_refreshes redraws a page that answers with itself in place, at the same scroll position, instead of replacing it whole. The page’s elements are kept and put back to what the page was drawn as, so a controller stays connected and anything it drew or changed is undone. A controller keeps what it owns by cancelling the morph for it:
<canvas ref="canvas"></canvas>
<script>
export default class extends Controller {
connect() {
this.keep = (event) => { if (event.target === this.canvasTarget) event.preventDefault() }
this.element.addEventListener("turbo:before-morph-element", this.keep)
}
disconnect() {
this.element.removeEventListener("turbo:before-morph-element", this.keep)
}
}
</script>

turbo:before-morph-attribute does the same for one attribute (event.detail.attributeName), such as a class the controller toggles.

A form’s answer is such a refresh only when it is the same page and the form’s button says so with data-turbo-action="replace". Without it, the answer replaces the page as it always did.

A branch’s preview runs the branch’s own JavaScript: its files, its pins and the controllers its pages name, as the branch has them. So a change whose effect is in a script can be looked at before it is merged.

A preview does not use Turbo, and a link in it is a full load of the live page. So what a script does across a turbo: visit or a refresh shows on the live site alone. Say so to the owner before merging a change whose effect is there.

Before setting a cookie, loading a tracker, or doing anything else a visitor was not asked about, a script needs the visitor’s answer. A site may carry its own bar: a form marked consent with an accept and a reject button, drawn while consent.pending, which is while the visitor has not answered. Without one the built-in bar asks. A file reads the answer as consent.granted. Draw the element that runs such a controller only under :if={consent.granted}.