# interaction

URL: /docs/generators/interaction

Realtime recordings of scripted interactions — a synthetic cursor driving the page, or a clip cropped to one component.

`interaction` records the page **live** while a scripted sequence plays out — a visible synthetic
cursor moves, clicks, hovers, and types, exactly like a hand-recorded product demo but reproducible
from config. It always records realtime (interactions and their animations are inherently
time-based) and always emits a single asset.

```ts
{
  name: "menu-demo",
  url: "https://your-site.com",
  generator: "interaction",
  options: { actions: [{ do: "click", selector: "#menu-button" }] },
}
```

Give it `actions` (a scripted tour), `focus` (a clip cropped to one component), or both — one of the
two is required. Site cleanup (hide the cookie banner, block trackers, seed a session cookie for
logged-in flows) comes from [`settings.capture`](/docs/configuration#capture), same as
every other URL capture — media keeps playing during the recording, since an interaction is live by
design.

## Config options

An interaction needs `actions`, `focus`, or both; everything else is optional. **Reference** is the
interactive view; **TypeScript** is the same shape in code, every option at its default.

**Reference**

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `output` | `Output` |  | Video output: size, scale, frame rate, encoding, filename. |
| `output.width` | `number` | `1280` | Output width in CSS px. |
| `output.height` | `number` | `800` | Output height in CSS px. |
| `output.deviceScaleFactor` | `number` | `2` | Render scale, max 4 — higher captures crisper, then downscales into the video. |
| `output.fps` | `number` | `30` | Output frames per second (max 120). |
| `output.crf` | `number` | `18` | x264 quality, 0–51 (lower = better quality / larger file). |
| `output.fileName` | `string` |  | Output filename. Defaults to "<slug(asset name)>.mp4". |
| `page` | `Page` |  | Page load + interaction timing. |
| `page.startDelayMs` | `number` | `500` | Dwell before the first step (ms). |
| `page.endDwellMs` | `number` | `800` | Dwell after the last step (ms). |
| `page.waitUntil` | `'load' \| 'domcontentloaded' \| 'networkidle' \| 'commit'` | `'networkidle'` | Page-load milestone to wait for before recording. |
| `page.waitForSelector` | `string` |  | Optional element to wait for before recording, e.g. a hero section. Omit to skip. |
| `page.stickyHeaderHeight` | `number` | `0` | Height (px) of a sticky/fixed header. scrollTo keeps targets clear of it: top-align drops them below it (coalesced with the target's own CSS scroll-margin-top — the larger wins, not summed — then offset stacks on top), center-align uses half of it, bottom-align is unaffected. |
| `actions` | `Action[]` | `[]` | The scripted steps, performed live with a visible synthetic cursor. Each step's do value picks its shape (below). Steps run back-to-back with no built-in pause — insert a wait step to hold between them. Omit only when focus alone (scroll-into-view + a wait) is enough — an interaction needs actions and/or focus. |
| `actions[do="move"]` | | | Glide the cursor to a target without clicking. Give a selector, or x/y for empty space. |
| `actions[do="move"].selector` | `string` |  | Element to glide onto. Omit to steer by x/y. |
| `actions[do="move"].x` | `number` |  | Viewport-relative X (0..1) when there is no selector. Steers the real pointer too, so gliding into empty space lifts :hover off whatever was under it. |
| `actions[do="move"].y` | `number` |  | Viewport-relative Y (0..1) when there is no selector. |
| `actions[do="move"].durationMs` | `number` | `700` | Cursor travel time (ms). |
| `actions[do="click"]` | | | Glide to an element and click it. |
| `actions[do="click"].selector` | `string` |  | Element to click. _(required)_ |
| `actions[do="click"].durationMs` | `number` | `700` | Cursor travel time to the element (ms). |
| `actions[do="hover"]` | | | Glide onto an element and rest, firing :hover. Follow with a wait to dwell. |
| `actions[do="hover"].selector` | `string` |  | Element to hover. _(required)_ |
| `actions[do="hover"].durationMs` | `number` | `700` | Cursor travel time to the element (ms). |
| `actions[do="type"]` | | | Type text into a field — the selector, or the already-focused field. |
| `actions[do="type"].text` | `string` |  | Text to type. _(required)_ |
| `actions[do="type"].selector` | `string` |  | Field to type into. Omit to act on the already-focused field. |
| `actions[do="type"].delayMs` | `number` | `55` | Ms between keystrokes (0 = instant). Humanized with mild jitter. |
| `actions[do="type"].easing` | `Easing` | `'linear'` | Eases the keystroke cadence across the run — "ease-in" starts quick and trails off, "ease-out" starts measured and quickens — without changing total time. |
| `actions[do="type"].durationMs` | `number` | `700` | Cursor travel time when a selector is given (the cursor glides to the field first). |
| `actions[do="erase"]` | | | Delete characters from a field — the selector, or the already-focused field. |
| `actions[do="erase"].selector` | `string` |  | Field to erase from. Omit to act on the already-focused field. |
| `actions[do="erase"].count` | `number` |  | How many characters to remove from the caret. Omit to erase the whole field. |
| `actions[do="erase"].delayMs` | `number` | `80` | Ms between keystrokes (0 = instant). Humanized with mild jitter. |
| `actions[do="erase"].easing` | `Easing` | `'linear'` | Eases the keystroke cadence across the run without changing total time. |
| `actions[do="erase"].durationMs` | `number` | `700` | Cursor travel time when a selector is given. |
| `actions[do="press"]` | | | Press a key, optionally as a chord with modifiers. |
| `actions[do="press"].key` | `string` |  | The key to press, e.g. "Enter", "Escape", "ArrowDown", or "f". _(required)_ |
| `actions[do="press"].modifiers` | `('Control' \| 'Shift' \| 'Alt' \| 'Meta')[]` |  | Modifier keys held during the press, e.g. ["Control"] for Ctrl+F. |
| `actions[do="scrollTo"]` | | | Scroll a target into view. |
| `actions[do="scrollTo"].to` | `number \| string` |  | Scroll target: a 0..1 number, an "NN%" string, or a CSS selector. _(required)_ |
| `actions[do="scrollTo"].align` | `'top' \| 'center' \| 'bottom'` | `'top'` | For a selector target: where it lands in the viewport. |
| `actions[do="scrollTo"].offset` | `number` |  | Px to nudge the resting position. Top-aligned, a positive offset leaves that many px of room above the target; negative scrolls past it. |
| `actions[do="scrollTo"].speed` | `number` | `400` | Scroll speed in CSS px/second — the default pacing. The step runs for as long as the distance needs (distance ÷ speed), so every scroll moves at the same steady, human pace regardless of how far it travels. |
| `actions[do="scrollTo"].durationMs` | `number` |  | A FIXED scroll time (ms) that overrides the default speed pacing — use it for a constant-time scroll, or durationMs: 0 for an instant jump (handy in setup). Omit to let speed pace it. |
| `actions[do="scrollTo"].easing` | `Easing` | `'ease-in-out'` | Shapes the scroll travel — "linear" holds a constant velocity (natural with speed), "ease-in-out" softens both ends. |
| `actions[do="wait"]` | | | Hold in place, doing nothing — the way to pause between steps. |
| `actions[do="wait"].durationMs` | `number` | `600` | How long to wait (ms). |
| `setup` | `Action[]` | `[]` | Off-camera steps run before recording starts — pre-position the cursor, scroll, or set UI state so frame 0 is exactly where you want it. Trimmed from the output. Key to a seamless loop: place the cursor on the first target here with durationMs 0 so the clip doesn't open with a glide-in from center. |
| `setup.do` | `'move' \| 'click' \| 'hover' \| 'type' \| 'erase' \| 'press' \| 'scrollTo' \| 'wait'` |  | What this step does (same Action shape as actions). _(required)_ |
| `teardown` | `Action[]` | `[]` | Off-camera steps run after recording ends — reset the page to its opening state. Trimmed from the output. (For a visible return-to-start that closes the loop on screen, put those steps at the end of actions instead — teardown never appears in the clip.) |
| `teardown.do` | `'move' \| 'click' \| 'hover' \| 'type' \| 'erase' \| 'press' \| 'scrollTo' \| 'wait'` |  | What this step does (same Action shape as actions). _(required)_ |
| `cursor` | `Cursor` |  | The synthetic cursor shown during the recording. Omit for the default cursor. |
| `cursor.show` | `boolean` |  | Show the cursor. Defaults to true. Set false for hands-off UI-state clips (e.g. media wall tiles). |
| `cursor.size` | `number` |  | Cursor size (px). Defaults to 22. |
| `cursor.color` | `string` |  | Cursor color. Defaults to white-with-shadow. |
| `focus` | `Focus` |  | Element-focused clip: scroll one component into view, optionally trigger/dwell it, and crop the output to its box. The crop box is measured AFTER any actions run, so an expanded dropdown/drawer stays in frame. Omit for a full-viewport recording. |
| `focus.selector` | `string` |  | Selector of the element to scroll into view and crop to. _(required)_ |
| `focus.padding` | `number` |  | Padding (px) around the element when cropping. Defaults to 24. |
| `focus.actions` | `Action[]` |  | Steps to trigger the component (e.g. open a dropdown) and/or dwell on it — end with a wait to hold on the element. |
| `colorScheme` | `'light' \| 'dark'` |  | Force a color scheme for the capture. Omit to leave as-is. |
| `strictSteps` | `boolean` | `false` | Fail the asset when any step fails (missing selector, click timeout), instead of logging a warning and shipping the clip without that step. |

**TypeScript**

```ts
{
  name: "menu-demo",
  url: "https://your-site.com",
  generator: "interaction",
  options: {
    // --- sizing & encoding ---
    output: {
      width: 1280,
      height: 800,
      deviceScaleFactor: 2,
      fps: 30,
      crf: 18,
      // fileName: "menu-demo.mp4",  // optional; defaults to <slug(name)>.mp4
    },

    // --- timing & navigation ---
    page: {
      startDelayMs: 500,
      endDwellMs: 800,
      waitUntil: "networkidle",
      // waitForSelector: "#hero",   // optional; wait for an element first
    },

    // --- the scripted tour (needs actions and/or focus) ---
    actions: [
      { do: "click", selector: "#menu-button" }, // durationMs 700 travel; add a { do: "wait" } to pause after
    ],
    // setup:    [], // off-camera steps before recording (pre-position cursor / scroll); trimmed out
    // teardown: [], // off-camera steps after recording (reset state); trimmed out
    // cursor: { show: true, size: 22, color: "#0b0b0f" }, // optional; default white-with-shadow

    // --- element-focused clip (alternative / addition to actions) ---
    // focus: { selector: "#pricing-card", padding: 24, actions: [{ do: "wait", durationMs: 2000 }] },

    // --- variant ---
    // colorScheme: "dark",          // optional; force light or dark
    // strictSteps: true,            // optional; fail the asset when a step fails (default: warn + continue)
  },
}
```

> Every option also has hover docs in `pro-visu.config.ts` — the authoring types are generated
> from the validation schema, so the editor always matches what the tool accepts.

## Examples

### Scripted tour

`actions` scripts a live click-through with a visible cursor — UI in a state you could never
screenshot from a static page. Clicks can navigate: the synthetic cursor survives page changes, so a
tour can move across the whole site. This one loops the journey — start on the home page, open the
shop and browse a module, hop to the About page for its hero, then click the wordmark home again and
pin the scroll to the top, landing exactly where it began.

```ts
{
name: "site-tour",
url: "https://your-site.com",
generator: "interaction",
options: {
  cursor: { color: "#8c7355" }, // the tinted synthetic cursor on screen
  setup: [{ do: "move", selector: ".wordmark", durationMs: 0 }], // park where the tour ends
  actions: [
    { do: "click", selector: ".nav-inline a:nth-child(1)" }, // → /shop
    { do: "wait", durationMs: 1300 },
    { do: "scrollTo", to: "#edit" },                         // speed-paced (~400 px/s)
    { do: "wait", durationMs: 700 },
    { do: "click", selector: ".edit-thumb:nth-child(2)" },
    { do: "wait", durationMs: 900 },
    { do: "click", selector: ".edit-thumb:nth-child(3)" },
    { do: "wait", durationMs: 900 },
    { do: "click", selector: ".edit-thumb:nth-child(4)" },
    { do: "wait", durationMs: 1000 },
    { do: "click", selector: ".nav-inline a:nth-child(3)" }, // → /about (hero)
    { do: "wait", durationMs: 2400 },
    { do: "click", selector: ".wordmark" },                  // → home
    { do: "wait", durationMs: 400 },
    { do: "scrollTo", to: 0, durationMs: 0 },                // instant pin to the top → loops
    { do: "wait", durationMs: 1400 },
  ],
},
}
```

*Output: A multi-page tour, looped: home → shop (browse The Edit) → About hero → back home, one continuous take that cuts cleanly into its own start.*

### Seamless loop

An interaction can't loop automatically — the tool doesn't know what your clicks did to the UI — but
`setup` and `teardown` give you the two off-camera windows to build one yourself. Both run the same
`Action` steps as `actions`, but neither appears in the output: `setup` is folded into the head trim
(before recording), `teardown` runs past the tail (after recording), so both are cut from the clip.

For a loop the **last frame must match the first**. Two moves get you there:

1. **`setup` pre-positions the cursor** on the first target (`durationMs: 0`), so frame 0 opens with the
   cursor already in place instead of gliding in from the center — cleaner even for non-looping clips.
2. **The last on-camera steps return to that opening state** — travel back to the first element and undo
   whatever the tour changed (reselect the default, close the menu or drawer). This is what actually
   closes the loop; `teardown` is only for off-camera cleanup, since it never shows.

Below, a size selector cropped with `focus`: `setup` selects **XS** off-camera so the clip opens on the
first size, the tour works up S → M → L → XL, and the final tap returns to **XS** — so the last frame
(XS selected, cursor resting on it) is identical to the first. Cropping to `.addbag` keeps the button
below the sizes in frame, so the whole component reads as one unit.

```ts
{
name: "size-loop",
url: "https://your-site.com/products/the-camel-coat",
generator: "interaction",
options: {
  cursor: { color: "#8c7355" }, // the tinted synthetic cursor on screen
  // off-camera: select XS so the loop opens on the first size
  setup: [{ do: "click", selector: ".size-options button:nth-of-type(1)", durationMs: 0 }],
  focus: {
    selector: ".addbag",
    padding: 24,
    actions: [
      { do: "click", selector: ".size-options button:nth-of-type(2)" }, // S
      { do: "wait", durationMs: 600 },
      { do: "click", selector: ".size-options button:nth-of-type(3)" }, // M
      { do: "wait", durationMs: 600 },
      { do: "click", selector: ".size-options button:nth-of-type(4)" }, // L
      { do: "wait", durationMs: 600 },
      { do: "click", selector: ".size-options button:nth-of-type(5)" }, // XL
      { do: "wait", durationMs: 600 },
      { do: "click", selector: ".size-options button:nth-of-type(1)" }, // back to XS → loops
      { do: "wait", durationMs: 900 },                                  // dwell on the element before the cut
    ],
  },
},
}
```

*Output: A self-contained component loop: start on XS, work up to XL, then land back on XS — the clip cuts seamlessly into its own start.*

A clean loop is realistic mainly for click / hover / scroll tours whose state you can fully restore.
Autoplaying video, running CSS animations, or late-loading content won't line up frame-to-frame.

### Typing, editing & keys

Three actions drive form input, and they read as a real person at the keyboard:

- **`type`** enters `text` a key at a time. `delayMs` sets the per-keystroke pace (default 55ms) and
  `easing` shapes the cadence across the word — `ease-in` starts quick and trails off, `ease-out`
  starts measured and quickens — without changing the total time. A little jitter is always mixed in
  so it never taps like a metronome.
- **`erase`** removes characters. Omit `count` to clear the whole field, or set it to edit just the
  tail (`count: 6`). It backspaces at `delayMs` (default 80ms) so the deletion is visible; `delayMs: 0`
  blanks it instantly.
- **`press`** sends one key (`key: "Enter"`), with optional `modifiers` for chords
  (`{ key: "f", modifiers: ["Control"] }` → Ctrl+F).

Below, a seamless loop over a search that filters **on submit** (Enter). `setup` lands the camera on
the search field (top-aligned, 40px below the sticky header). Search "trousers" → Enter → glance at
the result; then edit the query — erase, type "knitwear" → Enter → glance across the two results and
open the second; dwell on the product, then navigate back to the list so the last frame matches the
first.

```ts
{
name: "search-demo",
url: "https://your-site.com/shop",
generator: "interaction",
options: {
  cursor: { color: "#8c7355" }, // the tinted synthetic cursor on screen
  page: { waitForSelector: "#search-input", stickyHeaderHeight: 100 }, // header height → scrollTo lands below it
  setup: [
    { do: "scrollTo", to: "#search-input", align: "top", offset: 40, durationMs: 0 }, // start on the field (40px below the header)
    { do: "move", selector: "#search-input", durationMs: 0 },                          // park the cursor (frame 0)
  ],
  actions: [
    { do: "click", selector: "#search-input" },
    { do: "wait",  durationMs: 400 },
    { do: "type",  selector: "#search-input", text: "trousers", delayMs: 55, easing: "ease-out" },
    { do: "wait",  durationMs: 300 },
    { do: "press", key: "Enter" },                                     // submit → the list updates
    { do: "wait",  durationMs: 800 },
    { do: "hover", selector: ".product:first-child .product-media" },  // glance at the result
    { do: "wait",  durationMs: 1000 },
    { do: "erase", selector: "#search-input", delayMs: 80, easing: "ease-in" }, // clear the field
    { do: "wait",  durationMs: 300 },
    { do: "type",  selector: "#search-input", text: "knitwear", delayMs: 55 },   // edit the query
    { do: "wait",  durationMs: 300 },
    { do: "press", key: "Enter" },                                     // submit → two results
    { do: "wait",  durationMs: 800 },
    { do: "hover", selector: ".product:first-child .product-media" },  // ~1s on the first
    { do: "wait",  durationMs: 1000 },
    { do: "hover", selector: ".product:nth-child(2) .product-media" }, // ~1s on the second
    { do: "wait",  durationMs: 1000 },
    { do: "click", selector: ".product:nth-child(2) .product-link" },  // open the second
    { do: "wait",  durationMs: 2000 },                                 // dwell 2s
    { do: "click", selector: ".nav-inline a:nth-child(1)" },           // → back to the list
    { do: "wait",  durationMs: 900 },
    { do: "scrollTo", to: "#search-input", align: "top", offset: 40, durationMs: 800 }, // fixed-time glide to the start
    { do: "wait",  durationMs: 500 },
    { do: "move", selector: "#search-input", durationMs: 600 },        // cursor back on the field → loops
    { do: "wait",  durationMs: 1000 },
  ],
},
}
```

*Output: A search loop: submit a query with Enter, glance at the result, edit the query and submit again, open a result, then return to the list where it began — hand-driven typing and backspacing, and the camera set once via setup.*

> **The cursor never scrolls the page.** Moving, hovering, clicking or typing on a target assumes it's
> already in view — the viewport is a camera you drive **only** with `scrollTo` (an eased, explicit
> move). Here `setup` frames the search field once (`align: "top"`, `offset: 40`), and because its
> results sit just below, nothing needs to scroll again until the loop returns to that same start.
> Set `page.stickyHeaderHeight` to your header's height and top-aligned scrolls land **below** it —
> `offset` then stacks on top (here, 40px under the header). If an action's target is off-screen, the
> tool warns you to add a `scrollTo` rather than silently hard-jumping to it.
>
> **Scrolls are paced by distance, not a fixed time.** A `scrollTo` runs at `speed` (CSS px/second,
> default `400`) for as long as the distance needs — `distance ÷ speed` — so every scroll moves at the
> same steady, human pace whether it travels a whole page or a short hop. Raise or lower `speed` to taste;
> pair it with `easing: "linear"` for a true constant velocity (the default `ease-in-out` softens both
> ends, peaking \~2× mid-scroll). For a **fixed-time** scroll instead, set `durationMs` — and `durationMs: 0`
> jumps instantly, which is how `setup` pins the camera without an on-screen scroll.
>
> ```ts
> { do: "scrollTo", to: 1, speed: 1000, easing: "linear" } // to the bottom at a steady 1000 px/s
> { do: "scrollTo", to: 0, durationMs: 0 }                 // instant jump to the top (e.g. in setup)
> ```

### Element focus

`focus` scrolls one component into view, optionally triggers and dwells on it (end `focus.actions` with
a `wait`), and crops the output to its box — the crop is measured after the actions, so an expanded
state stays in frame. This one
opens at rest with the cursor parked just off the card (a selector-less `move` steers the real pointer
too, so nothing is hovered). The cursor glides on — the controls reveal and the image zooms — the
wishlist fills, the button confirms **Added to bag**, the heart clears, then the cursor glides back off
the card, which lifts the hover and settles it to rest again: an exact match for the opening frame.

```ts
{
name: "card-demo",
url: "https://your-site.com/shop",
generator: "interaction",
options: {
  cursor: { color: "#8c7355" }, // the tinted synthetic cursor on screen
  setup: [{ do: "move", x: 0.16, y: 0.87, durationMs: 0 }], // rest: cursor just off the card
  focus: {
    selector: "#feature-card",
    padding: 26,
    actions: [
      { do: "hover", selector: "#feature-card .product-media" }, // reveal + zoom
      { do: "wait", durationMs: 900 },
      { do: "click", selector: "#feature-card .wishlist" },      // heart fills
      { do: "wait", durationMs: 1000 },
      { do: "click", selector: "#feature-card .quick-add" },     // → "Added to bag"
      { do: "wait", durationMs: 1300 },
      { do: "click", selector: "#feature-card .wishlist" },      // heart clears
      { do: "wait", durationMs: 700 },
      { do: "move", x: 0.16, y: 0.87, durationMs: 600 },         // glide off → card resets → frame 0
      { do: "wait", durationMs: 1100 },
    ],
  },
},
}
```

*Output: Cropped to one product card, looped: glide in to reveal the controls, fill the wishlist, add to bag, then glide off — the hover lifts and the card resets to where it started.*
