Skip to content

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.

clickzoomtypeclickzoomOutzoom
Issues
  • PT-12Billing shows a stale plan after upgradeBug
  • PT-11Keyboard shortcut sheetFeature
  • PT-9Tighten empty state spacingPolish
  • PT-7Export issues as CSVFeature
This is markup on the page, not a video.

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.

Agent skill
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
npm i playthru

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

hero.html
<div id="hero" class="h-[520px]">
  <div><!-- your real app UI --></div>
</div>
hero.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
<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.

NumberWhy
Glide420ms + 0.34ms/pxmax 980msShort 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 distanceHands move from the wrist, in a slight curve. A straight line reads as a robot; much more reads as drunk.
Settle180msThe 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.
Ripple560msLong enough to show up in a 30fps recording, gone before the next action.
Typing48ms + 0–42ms+150ms after punctuationAround 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 stepsSequential actions should read as separate. run() steps are bookkeeping and get no gap.
Hold1.9sThe 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.

StepWhat 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.
playingtrue while the demo is moving, false while it's paused, hovered, off screen or not started.
OptionWhat it does
loop: trueReplay forever.
autoplay: trueStart the first time the stage is 30% in view.
pauseOnHover: trueA 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
.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.