Self-hosted interactive product demos, built from screenshots. Capture a flow once, embed a clickable walkthrough anywhere. No SaaS, no monthly fee, no vendor lock-in. The embed is plain static files you host yourself, so it never breaks because someone stopped paying.
The GIF is a preview. Try the live, clickable version: https://jgalea.github.io/flowshot/examples/demo/
Three independent pieces:
- A capture CLI (Playwright) that runs a flow you describe in a small JSON file, screenshots each step, and places the hotspot on the element you interact with.
- A player: one dependency-free JavaScript file that renders the captured demo as a clickable walkthrough, with optional chapters, overlay cards, and branching buttons. Embed it with a single script tag, on WordPress or any static page.
- A browser editor for touching up captions, CTAs, and hotspot positions after capture.
The output is a folder of PNGs plus one demo.json. Drop it on GitHub Pages, Netlify, Cloudflare, an S3 bucket, or your CMS uploads. The player resolves everything relative to that folder.
The player reserves the demo frame with the declared size before media loads, preloads the next image/poster for snappy navigation, and keeps video clips lazy with native metadata loading.
If a demo JSON file is missing, malformed, or fails player validation, flowshot renders a small fallback message in the embed and logs a warning instead of leaving a blank box.
npm install -g flowshot
# or run without installing:
npx flowshot --help
Capture needs a browser the first time:
npx playwright install chromium
Describe the flow you want to capture:
{
"title": "Add your first feed",
"baseUrl": "https://app.example.com",
"viewport": { "width": 1280, "height": 800 },
"theme": { "accent": "#2563eb" },
"steps": [
{ "goto": "/", "caption": "This is your dashboard. Click Add feed to begin.", "click": "#add-feed" },
{ "caption": "Paste any address.", "fill": { "selector": "#feed-url", "text": "https://example.com/feed" } },
{ "caption": "Import it.", "click": "text=Import" },
{ "waitFor": ".feed", "caption": "Done. Your feed is live.", "cta": { "text": "Try it", "url": "https://example.com" } }
]
}Capture, preview, embed:
flowshot capture my-flow.json --out demo
flowshot preview demo/demo.json
Generate a crawler-friendly share page when you want rich link previews:
flowshot share demo/demo.json --out demo/share.html --base https://example.com/demos/add-feed/
Each step describes one screen plus the action that advances to the next. The screenshot is taken in the state shown; the hotspot is drawn on the element you act on.
| Key | Meaning |
|---|---|
goto |
Navigate to a path (joined with baseUrl) or absolute URL before this screen |
click |
Selector to highlight, then click to advance |
fill |
{ selector, text } typed before the screenshot so the value is visible |
hover |
Selector to hover (reveal a menu) before the screenshot |
press |
A key to press to advance (e.g. Enter) |
waitFor |
Selector to wait for before screenshotting |
caption |
Text shown for this step |
cta |
{ text, url } button, typically on the final step |
type |
Optional. Omit it or use "shot" for the normal screenshot step; use "card" for an overlay card; use "video" for a native video step |
Selectors are Playwright locators, so #id, .class, text=Save, and role=button[name="Save"] all work.
Capture options: --out <dir>, --base <url>, --headed, --scale <n> (default 2), --settle <ms>, --pad <px>.
Card steps pass through without taking a screenshot:
{
"type": "card",
"title": "What do you want to do?",
"subtitle": "Choose a path.",
"background": "shots/01.png",
"blur": "medium",
"buttons": [
{ "text": "Setup", "goTo": "#setup" },
{ "text": "Restart", "goTo": "restart" }
]
}Add top-level chapters to render a chapter menu:
{
"chapters": [
{ "id": "intro", "title": "Intro", "start": 0 },
{ "id": "setup", "title": "Setup", "start": 2 }
]
}Card button goTo accepts a step index, #chapterId, next, prev, restart, or an http(s):// URL.
Each step may also include an optional stable id such as "add-feed" or "choose-path". If omitted, the player derives one from the step title/caption with a step-N fallback. Analytics events include both the legacy numeric index and the durable step id, so old demos keep working while new demos can use IDs for links and reporting.
Video steps render a native <video> in the same stage as screenshots, so captions and hotspots still overlay correctly:
{
"type": "video",
"video": {
"src": "media/clip.webm",
"poster": "shots/02.png",
"tracks": [{ "src": "media/clip.en.vtt", "srclang": "en", "label": "English", "default": true }],
"transcript": "media/clip-transcript.txt"
},
"caption": "Watch the import finish.",
"autoplay": true,
"muted": true,
"loop": false,
"controls": false,
"advanceOnEnd": true
}Use .mp4 or .webm for portable embeds. HLS (.m3u8) is only supported where the browser can play it natively, such as Safari; flowshot does not bundle hls.js. Video steps can include provided WebVTT caption tracks and an optional transcript string or transcript file path; flowshot renders tracks as native <track kind="captions"> elements and shows transcripts in a collapsible panel.
Shot, card, video, and form steps can set focus to "hotspot", "cursor", "zoom", or "spotlight". hotspot keeps the classic outlined target, cursor adds a synthetic cursor at the hotspot, zoom scales the whole media layer toward the target region, and spotlight dims the surrounding stage. Motion is restrained and gated by prefers-reduced-motion; reduced-motion users get the static state without glide/zoom transitions.
Zoom applies to the shared stage inner layer, so screenshots, video, hotspots, cursor focus, and annotations stay tethered. Use zoomLevel to set the scale, defaulting to 1.7, and focusPoint to choose the zoom origin when there is no hotspot:
{
"image": "shots/dense-ui.png",
"focus": "zoom",
"zoomLevel": 1.8,
"focusPoint": { "x": 64, "y": 42 },
"annotations": [{ "type": "box", "x": 58, "y": 36, "w": 18, "h": 12 }]
}Steps can also include non-interactive annotations in the same normalized coordinate space as hotspots:
{
"image": "shots/02.png",
"hotspot": { "x": 60, "y": 20, "w": 10, "h": 8 },
"annotations": [
{ "type": "box", "x": 58, "y": 18, "w": 14, "h": 12, "color": "#E2126A" },
{ "type": "label", "x": 42, "y": 16, "w": 18, "h": 8, "text": "Check this setting" },
{ "type": "arrow", "x": 52, "y": 23, "w": 10, "h": 8 }
]
}Annotation coordinates use percentages of the stage. box draws an outline, label draws a text chip, and arrow draws a simple SVG arrow. They are pointer-inert so hotspots remain clickable.
Form steps capture leads without coupling flowshot to a CRM:
{
"type": "form",
"title": "Want the checklist?",
"subtitle": "Send yourself the setup guide.",
"fields": [
{ "name": "name", "label": "Name", "type": "text", "required": true },
{ "name": "email", "label": "Email", "type": "email", "required": true }
],
"submit": { "text": "Send checklist", "action": "https://example.com/leads" }
}submit.action is optional. When present, flowshot POSTs the submitted field values as JSON. With or without an action, the player emits lead_submit analytics with only the field names present, never the submitted values.
The player uses real buttons, native forms, and native video controls. It also includes a polite live region that announces the current step and chapter, descriptive dot-button labels based on step content, visible focus outlines, keyboard-friendly Back/Next/hotspot controls, and a chapter menu with menu semantics, Escape, arrow-key navigation, and focus return. Form steps use explicit status text tied to invalid fields instead of relying only on native browser blocking. Motion-sensitive behavior honors prefers-reduced-motion.
One script tag and a div. The script injects its own styles and auto-mounts every [data-flowshot] element:
<div data-flowshot="/demos/add-feed/demo.json"></div>
<script src="/flowshot/flowshot.js"></script>Where script tags are awkward (some CMSes), use the iframe player instead:
<iframe src="/flowshot/player.html?demo=/demos/add-feed/demo.json"
style="width:100%;aspect-ratio:1280/800;border:0"></iframe>Programmatic control:
<div id="demo"></div>
<script src="/flowshot/flowshot.js"></script>
<script>
new Flowshot("#demo", "/demos/add-feed/demo.json", { accent: "#16a34a", autoplay: false });
</script>Optional attributes: data-autoplay, data-interval="3000", data-accent="#16a34a".
Deep-linking is available without changing old embeds. In iframe mode, add step=<id-or-index> or chapter=<chapterId> to open at a specific point:
<iframe src="/flowshot/player.html?demo=/demos/add-feed/demo.json&step=add-feed"></iframe>Script embeds leave the host page hash alone by default. Opt in with data-deeplink or { deepLink: "flowshot" }; the player then reads and writes prefixed hashes such as #flowshot-step-add-feed, so it does not hijack ordinary page anchors.
Progress persistence is also opt-in. Add data-persist-progress or { persistProgress: true } to store only the current step index/id in localStorage. On return, flowshot shows a small "Resume" / "Restart" prompt instead of silently jumping. Form field values are never persisted.
Personalization variables let one demo adapt per viewer without executable payloads. Add defaults at the top level, then use {{name}} or {{name|Fallback text}} in text fields such as captions, card titles/subtitles, button text, CTA text, form labels, and transcripts. URL query params override defaults, so /flowshot/player.html?demo=/demo.json&company=Acme can render {{company}} as Acme. Substitution is plain string replacement and still renders through textContent, so HTML-looking values display as text instead of markup. Analytics payloads include only the variable names resolved from the URL, never their values.
{
"variables": { "company": "your team" },
"steps": [
{ "image": "shots/01.png", "caption": "Welcome, {{company|there}}." }
]
}Share pages are build-time HTML files for crawlers that do not run JavaScript. Add optional top-level description and poster fields to demo.json, then run flowshot share. The generated page puts the social meta block first in <head> for Slack's early parser, bakes the Open Graph tags (og:title, og:description, og:image, og:image:width, og:image:height, og:image:alt, og:url, og:type, og:site_name), includes twitter:card=summary_large_image, and adds Twitter title/description/image tags when values are present.
Use a 1200 x 630 PNG for poster, keep it under 500 KB so it stays below WhatsApp's 600 KB ceiling, and keep the bottom ~120 px clear of critical text or logos because X overlays a domain pill in the bottom-left corner of large image cards.
Use demo.theme.preset or data-preset for brand-ready embeds:
aggregator: accent#3D6B5E, secondary#E8836B, surface#FAF6F3, text#2D3B35, radius10pxspotlight: accent#E2126A, chrome/text#2D344B, surface#fff, radius12px
An explicit theme.accent or data-accent still overrides the preset accent.
WP RSS Aggregator script embed:
<div data-flowshot="/demos/add-feed/demo.json" data-preset="aggregator"></div>
<script src="/flowshot/flowshot.js"></script>WP RSS Aggregator iframe embed:
<iframe src="/flowshot/player.html?demo=/demos/add-feed/demo.json&preset=aggregator&fit=1"
style="width:100%;aspect-ratio:1280/800;border:0"></iframe>Spotlight script embed:
<div data-flowshot="/demos/add-feed/demo.json" data-preset="spotlight"></div>
<script src="/flowshot/flowshot.js"></script>Spotlight iframe embed:
<iframe src="/flowshot/player.html?demo=/demos/add-feed/demo.json&preset=spotlight&fit=1"
style="width:100%;aspect-ratio:1280/800;border:0"></iframe>Use onEvent(type, payload) when you mount programmatically:
<div id="demo"></div>
<script src="/flowshot/flowshot.js"></script>
<script>
new Flowshot("#demo", "/demos/add-feed/demo.json", {
onEvent: function (type, payload) {
console.log(type, payload);
}
});
</script>Or add a beacon endpoint to the demo file:
{
"analytics": { "beacon": "https://example.com/flowshot-events" }
}For CMS embeds, you can also pass the endpoint on the element:
<div data-flowshot="/demos/add-feed/demo.json"
data-analytics-url="https://example.com/flowshot-events"></div>
<script src="/flowshot/flowshot.js"></script>Events are ready, step_view, chapter_view, cta_click, branch_click, lead_submit, complete, and dropoff. Beacon delivery uses navigator.sendBeacon with a fetch(..., { keepalive: true }) fallback. Payloads only include the demo title plus step indices, step ids, step types, chapter ids, CTA URLs, branch labels/targets, and lead field names. Disable analytics with { analytics: false } or data-analytics="false".
UTM parameters (utm_source, utm_medium, utm_campaign, utm_term, utm_content) and document.referrer are captured once on init and included on every analytics payload under attribution.
To also push events into window.dataLayer, opt in with the demo file:
{
"analytics": { "dataLayer": true }
}Or from a script embed:
<div data-flowshot="/demos/add-feed/demo.json" data-datalayer></div>
<script src="/flowshot/flowshot.js"></script>Open editor/index.html to adjust a captured demo without re-running it: drag hotspots, rewrite captions, edit CTAs, reorder or delete steps, then download the updated demo.json.
flowshot preview demo/demo.json # serve the demo folder
# then open editor/index.html?demo=http://localhost:8080/demo.json
{
"title": "Add your first feed",
"description": "A short walkthrough for publishing link previews.",
"poster": "shots/01.png",
"theme": { "accent": "#2563eb" },
"size": { "width": 1280, "height": 800 },
"chapters": [{ "id": "intro", "title": "Intro", "start": 0 }],
"steps": [
{
"id": "add-feed",
"image": "shots/01.png",
"caption": "Click Add feed to begin.",
"hotspot": { "x": 88.6, "y": 3, "w": 9, "h": 6 },
"cta": null
},
{
"type": "card",
"title": "Choose a path",
"subtitle": "Branch to the section you need.",
"buttons": [{ "text": "Intro", "goTo": "#intro" }]
},
{
"type": "video",
"video": {
"src": "media/clip.webm",
"poster": "shots/01.png",
"tracks": [{ "src": "media/clip.en.vtt", "srclang": "en", "label": "English" }]
},
"caption": "Show a short screen recording.",
"muted": true
}
]
}Hotspot coordinates are percentages of the image or video, so the demo stays aligned at any width. hotspot may be null for a step with no click target. Card steps are overlay slides; buttons can branch to chapters, specific steps, navigation keywords, or external URLs.
flowshot competes with hosted interactive-demo tools: Arcade, Supademo, Storylane, and Navattic. Those are more full-featured (AI voiceover, analytics, captured DOM) and need no setup, but the demos you publish run from the vendor's servers. Embeds stop working if you cancel, and you pay every month to keep them alive.
flowshot is the choice when you want to own the output outright: capture once, host the static files yourself, pay nothing to keep them running.
flowshot covers click-through demos for marketing pages, onboarding, and support, including simple chapters, overlay cards, branching paths, native video clips, and lightweight analytics events. It does not do AI voiceover. Screenshots are flat images, not captured DOM, so you cannot edit the page text after capture. If you need those, a hosted tool is a better fit. If you want demos you own outright at zero running cost, this is for you.
MIT
