---
title: Client scripts and behaviours in vzk
description: 'Use this guide when a vzk site needs something to happen in the browser:
  a menu that opens, a gallery, a tab set.'
sidebar:
  order: 9
---

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's client script

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

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

## Commands without a controller

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

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

## Loading a controller later

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:

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

## Behaviours the platform gives

`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):

```html
<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 in a file of its own

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:

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

## Imports and packages

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.

## Fades and in-place refreshes

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:

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

## Seeing it work

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.

## Consent

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