# Product demos on your real UI

Your hero demo is a 6 MB video that went stale the week after you recorded it. `playthru` plays the demo on the interface itself: a cursor glides, clicks and types, and a camera zooms in on what matters.

Write the script once, as a chain of steps. It stays sharp at any size, follows your theme, never drifts out of date with the product, and weighs about 5 KB.

## How it works

Every step is a small piece of hand motion. A click isn't a jump cut: the cursor travels there in a slight arc, rests for a beat so your eye catches up, presses, and lets go.

`glide` → `settle` → `press` → `release`

The press fires real `pointerdown`, `mousedown`, `pointerup`, `mouseup` and `click` events on the element, so your app's own handlers run. Typing writes through the native value setter and fires `input`, so React and Vue controlled fields update too.

The camera is a transform on the stage's only child. The cursor lives inside it, so it scales with the zoom and stays on its target while the camera moves.

## Install

The quickest way is to let your coding agent do it. Add the [agent skill](https://github.com/diip3sh/playthrou/blob/main/skills/playthru/SKILL.md) to your project.

Agent skill:

```bash
npx skills add diip3sh/playthrou
```

Then ask in plain words. The skill tells the agent how playthru works, so it can install it (or load it from a CDN on a plain HTML page), build the stage, write the script and the reset, and mirror your hover styles.

Prompts:

```
Add a playthru demo of the issue composer to the hero.
Put a self-playing demo of this page in the landing page, no npm.
My playthru demo starts the second loop in the wrong state. Fix it.
```

To set it up by hand, add the package. It has no dependencies and no stylesheet to import.

Terminal:

```bash
npm i playthru
```

Wrap the UI you want to show in a stage, then write the script.

hero.html:

```html
<div id="hero" class="h-[520px]">
  <div><!-- your real app UI --></div>
</div>
```

hero.ts:

```ts
import { playthru } from "playthru";

playthru("#hero")
  .click("#new-issue")
  .zoom("#composer")
  .type("#title", "Fix onboarding flicker on Safari")
  .click("#create")
  .zoomOut()
  .zoom(".issue:first-child")
  .reset(() => app.reset());
```

It starts the first time the stage scrolls into view and loops forever. In React, import `Playthru` from `playthru/react` and pass the same chain as a function.

Hero.jsx:

```jsx
<Playthru
  className="h-[520px]"
  script={(d) => d.type("#todo", "Record the launch video").click("#add")}
>
  <TodoApp />
</Playthru>
```

## The numbers

There is no `speed`, `easing` or `duration` option, and there won't be one. The numbers are the product: each one is a decision you no longer have to make.

| Number | Why |
| --- | --- |
| Glide: `420ms + 0.34ms/px`, `max 980ms` | Short hops shouldn't feel sluggish and long ones shouldn't teleport. A move across a 900px hero lands just under a second. |
| Arc: `10% of the distance` | Hands move from the wrist, in a slight curve. A straight line reads as a robot; much more reads as drunk. |
| Settle: `180ms` | The beat between arriving and clicking. Without it, eyes can't catch up to what got clicked. |
| Press: `90ms`, `cursor at 84%` | A real click is 60 to 120ms. The squeeze makes it readable at video frame rates. |
| Ripple: `560ms` | Long enough to show up in a 30fps recording, gone before the next action. |
| Typing: `48ms + 0–42ms`, `+150ms after punctuation` | Around 130 words a minute: quick enough to not bore you, uneven enough to look human. It is seeded, so every loop types the same. |
| Zoom: `1050ms`, `1.3× to 2.2×` | The target fills half the stage. Below 1.3× a zoom looks like a mistake; above 2.2× you lose where you are. If the target can't fit at 1.3×, as on a phone, the camera stays out. |
| Follow: `8% from an edge`, `nudge to 22%` | While zoomed in, the camera pans only when the cursor heads for the edge, and only as far as it must. It never recenters on every move. |
| Gap: `240ms between steps` | Sequential actions should read as separate. run() steps are bookkeeping and get no gap. |
| Hold: `1.9s` | The payoff has to land before the loop starts over. |

One easing curve, a cubic in-out, drives both the cursor and the camera, so the two never disagree about when a move starts or lands.

## API

`playthru(stage, options?)` takes a selector or an element and returns a chain. Targets are selectors scoped to the stage, or elements. They resolve when the step runs, so a step can target something an earlier step created.

| Step | What it does |
| --- | --- |
| `move(target)` | Glides the cursor to an element. |
| `click(target?)` | Glides there, presses, ripples and fires real pointer and click events. Without a target it clicks the last one. |
| `type(target?, text)` | Clicks the field, then types into it with human timing. Typed fields restore themselves on every loop. |
| `key(key)` | Fires keydown and keyup on the last target, for example key("Enter"). |
| `zoom(target?)`, `zoomOut()` | Pushes the camera in on an element, or the last target, and pulls it back. |
| `wait(ms)` | Holds for a moment. |
| `run(fn)` | Runs your own code as a step, with no pause after it. |
| `reset(fn)` | Runs before every pass, to put your app back where it started. |
| `play()` | Starts the demo, or resumes it after pause(). With autoplay: false, this is how it starts. |
| `pause()` | Freezes the demo mid-motion. play() picks it up exactly where it stopped. |
| `destroy()` | Stops for good: removes the cursor and caret, and puts the stage and typed fields back. |
| `playing` | true while the demo is moving, false while it's paused, hovered, off screen or not started. |

| Option | What it does |
| --- | --- |
| `loop: true` | Replay forever. |
| `autoplay: true` | Start the first time the stage is 30% in view. |
| `pauseOnHover: true` | A real pointer over the stage pauses the demo and hides the fake cursor, so visitors can try it themselves. |

Options say whether, never how. Timing is not an option.

## Hover and focus

A fake cursor can't trigger `:hover`, and playthru never moves real keyboard focus, so it can't trigger `:focus` either. Instead it marks the element under the cursor and the field being typed in.

app.css:

```css
.button:hover,
.button[data-playthru-hover] {
  background: var(--button-hover);
}

input[data-playthru-focus] {
  outline: 2px solid var(--accent);
}
```

In Tailwind, the attributes work as variants: `hover:bg-gray-200 data-playthru-hover:bg-gray-200`. The text caret is drawn by playthru, in the field's own text color.

## Limitations

- The stage needs a single child. If it has several, playthru wraps them in a div and unwraps them on destroy.
- Elements inside iframes, canvas and WebGL scenes can't be targeted, because the cursor needs a DOM element to aim at.
- The drawn caret appears in single-line inputs only. Textareas and contenteditable get typed into without one.
- Clicks on links and submit buttons have their default action prevented, so a demo never navigates. Handle the click in your code if it should do something.
- The demo's events reach the whole page. While a menu, listbox or popover outside the stage is open, the demo pauses so its clicks can't close it. Popups are found by `aria-expanded="true"` on their trigger or by `:popover-open`. A custom popup with neither should ignore events where `event.isTrusted` is false.
- Your app state is yours to reset. Typed fields restore themselves; everything else goes in `reset()`.

With `prefers-reduced-motion`, the cursor jumps instead of gliding, the camera stays still, and loops cut without fades.

## FAQ

### Why not just record a video?

A video is several megabytes, blurry on large screens, wrong in dark mode, and out of date the moment the product changes. A playthru demo is your current UI, rendered by the browser at whatever size and theme the visitor has.

### Does it ship any CSS?

It injects one small style tag for the cursor, ripple and caret the first time it runs. There is nothing to import.

### Will it steal focus or scroll the page?

No. Real focus never moves, so a visitor typing elsewhere on the page is never interrupted. The demo also pauses when it scrolls out of view and when the tab is in the background.

### Can visitors use the demo?

Yes. With `pauseOnHover`, which is on by default, a real pointer over the stage freezes the demo mid-motion and hides the fake cursor. It picks up exactly where it was when the pointer leaves.

### Which frameworks does it support?

Anything that renders DOM. There is a React component and hook in `playthru/react`; everywhere else, call `playthru()` after mount and `destroy()` on unmount.

### How big is it?

About 5 KB gzipped for the core and 0.3 KB for the React wrapper, with zero dependencies.

- [playthru on npm](https://www.npmjs.com/package/playthru)
- [Source on GitHub, MIT licensed](https://github.com/diip3sh/playthrou)

Source: https://playthrou.diip3sh.xyz/
