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.
- PT-12Billing shows a stale plan after upgradeBug
- PT-11Keyboard shortcut sheetFeature
- PT-9Tighten empty state spacingPolish
- PT-7Export issues as CSVFeature
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 to your project.
npx skills add diip3sh/playthrouThen 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.
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.
npm i playthruWrap the UI you want to show in a stage, then write the script.
<div id="hero" class="h-[520px]">
<div><!-- your real app UI --></div>
</div>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.
<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 |
|---|---|
| Glide420ms + 0.34ms/pxmax 980ms | Short hops shouldn't feel sluggish and long ones shouldn't teleport. A move across a 900px hero lands just under a second. |
| Arc10% of the distance | Hands move from the wrist, in a slight curve. A straight line reads as a robot; much more reads as drunk. |
| Settle180ms | The beat between arriving and clicking. Without it, eyes can't catch up to what got clicked. |
| Press90mscursor at 84% | A real click is 60 to 120ms. The squeeze makes it readable at video frame rates. |
| Ripple560ms | Long enough to show up in a 30fps recording, gone before the next action. |
| Typing48ms + 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. |
| Zoom1050ms1.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. |
| Follow8% from an edgenudge 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. |
| Gap240ms between steps | Sequential actions should read as separate. run() steps are bookkeeping and get no gap. |
| Hold1.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.
.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 whereevent.isTrustedis 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.