
Guided Demo
- 39 installs
- 154 repo stars
- Updated July 30, 2026
- sammcj/agentic-coding
Helps with ai & agent building tasks.
About
guided-demo is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- guided-demo
- AI & Agent Building
- AI-coding skill
Guided Demo by the numbers
- 39 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #8,260 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sammcj/agentic-coding --skill guided-demoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 39 |
|---|---|
| repo stars | ★ 154 |
| Last updated | July 30, 2026 |
| Repository | sammcj/agentic-coding ↗ |
What it does
Helps with ai & agent building tasks.
Files
Guided Demo Pattern
Add a self-narrating walkthrough to any HTML/web application. A declarative step array drives a setTimeout loop that toggles CSS classes on DOM elements and writes text character-by-character into a fixed panel. The engine overlays the existing page without modifying its code. When the demo stops, all state resets.
About 100 lines of JS and 30 lines of CSS for the core. Interstitials, speed controls, keyboard shortcuts, and progress bar are optional layering.
Before implementing
Ask the user these questions (skip any already answered in conversation):
1. What are the sections? Tabs, routes, scroll positions, slide indices? Determines how section switching works. 2. Framework? Vanilla HTML works directly. React/Vue/Svelte need the engine as a conditional overlay component, and querySelector must run after render. 3. Presenter talking over it, or self-narrating? Talking over: shorter text, longer pauses. Self-narrating: detailed text, moderate pauses. 4. Interactive elements to trigger? Map to step actions. Confirm CSS selectors are stable (not framework-generated hashes). 5. Offline requirement? Keep everything in one file if so. No CDN dependencies without fallbacks. 6. How many steps? Under 15 for a pitch, 20-30 for a detailed walkthrough. Over 40 loses attention. 7. Touch device support needed? Most guided demos target desktop/laptop presentation contexts. The narrator panel has on-screen prev/play/next buttons that work on touch, but swipe gestures or mobile-specific layout are rarely needed.
Implementation
Read references/implementation.md for all code snippets (vanilla JS / static HTML). If the target application uses React with React Router, also read references/react-integration.md for React-specific patterns covering stale closures, route navigation timing, and component architecture. For other SPA frameworks (Vue, Svelte, etc.), the same concepts apply (poll for elements after route change, store timer-relevant state outside the reactivity system) but the implementation details differ.
The four required pieces are:
1. Narrator panel - fixed-position bar with text container and progress bar 2. Highlight class - outline (not border) with animated glow, no layout shift 3. Typewriter function - recursive setTimeout, narration text set via textContent (never set user-authored narration via innerHTML), blinking cursor via CSS. The innerHTML = '' clearing before each tick is intentional and safe (no user content involved) 4. Playback loop - playStep(idx) that switches sections, runs actions, highlights, types, and auto-advances
Step array
The single source of truth. Each step is a plain object:
const DEMO_SCRIPT = [
{ section: 0, target: '.card', text: "Narration text." },
{ section: 1, target: null, text: "Transition.", transition: true },
{ section: 1, target: '#el', text: "Detail.", action: 'open', actionTarget: 'panel-id' },
];Properties: section (which view), target (CSS selector or null), text (narrator copy), transition (show interstitial), action/actionTarget (trigger UI change), delay (optional per-step pause override in ms, defaults to PAUSE_MS).
Timing defaults
| Constant | Default | When to adjust |
|---|---|---|
| TYPE_SPEED | 5ms/char | 3ms for long text, 15ms for dramatic short statements |
| PAUSE_MS | 3000ms | 4-5s if audience reads rather than listens |
| Speed range | 0.5x-2x | Both constants divided by multiplier |
Writing narrator copy
The typewriter effect means each word lands individually, so writing style matters:
- Short declarative sentences. Each step should make exactly one point.
- Conversational tone, present tense. Address the audience directly.
- Describe what the element means, not what the UI shows: "This column quantifies the cost impact" not "The cost is shown in this column".
- No jargon the audience wouldn't know. Match terminology to the domain, not the implementation.
- Under 30 words per step for presenter-led demos, up to 50 for self-narrating.
Keyboard controls
Gate all keyboard capture behind an isActive flag so it does not interfere with normal page interaction. Space = play/pause, arrows = step, M = toggle TTS narration, Escape = exit and reset.
Text-to-speech narration
Browser-native TTS using the speechSynthesis Web Speech API. Reads each step's narration aloud alongside the typewriter effect. Include in all guided demos but must be off by default - never start speaking without explicit user action. The user toggles TTS via a speaker icon in the control bar (dimmed when off, full opacity when on).
Key requirements:
- Default off. The toggle icon renders in the control bar but TTS is muted until the user clicks it.
- Voice selection. Voices load asynchronously in some browsers. Listen for
speechSynthesis.addEventListener('voiceschanged', ...)and cache the selected voice. Implement a preference cascade: filterspeechSynthesis.getVoices()by predicate functions in priority order, returning the first match. Adjust the locale cascade to suit the project's target audience (e.g. en-AU, en-GB, en-US). - Playback integration. Call
speakText()at the start of every step, before both the typewriter branch (auto-play) and the instant-text branch (paused/manual stepping). One call site, not two. Setutterance.rateto track the demo's speed setting. - Cancellation.
speechSynthesis.cancel()must be called in:clearAllTimers(),stopDemo(), component unmount cleanup, and inside the mute toggle when muting.speakText()itself should cancel before speaking so stepping to a new step cuts off the previous utterance. Guard everyspeechSynthesiscall withif ('speechSynthesis' in window)for SSR/test environments. - Keyboard shortcut. M to toggle. Gate behind the
isActiveflag and skip when focus is in form inputs, same as existing keyboard controls.
Step countdown indicator
A subtle progress bar that fills left-to-right during the pause after the typewriter text finishes, showing how much time remains before auto-advancing. Gives the viewer a sense of pacing without being distracting. Include in all guided demos.
Key design decisions:
- CSS animation, not JS intervals. The fill uses a
@keyframesanimation withanimation-durationset dynamically from the actual pause duration. NosetInterval, norequestAnimationFrame. The browser handles smooth rendering. - Placement. Directly below the overall step progress bar (the "step X of Y" bar), above the narrator text. This keeps the two progress indicators visually grouped. Do not place it between the narrator text and the controls.
- Cleanup via `clearAllTimers()`. Every action that interrupts the current step (manual step, pause, stop) calls
clearAllTimers()first. Resetting the countdown there means you never need to clear it elsewhere. - No countdown on last step. The
onDonecallback only starts the countdown whenstepIdx < script.length - 1. There is nothing to count down to on the final step. - Speed changes mid-countdown. The current bar keeps its original duration. The new speed applies to the next step's countdown. Restarting the animation mid-step to match the new speed is not worth the complexity.
- Visual tuning. 2px height. Container background at
rgba(255, 255, 255, 0.06)reads as a subtle track when empty. Fill colour should be the application's accent colour at 0.6-0.8 opacity. Uselineartiming, notease- the viewer reads it as a countdown and easing makes the remaining time harder to judge.
Optional: transition interstitials
Full-screen overlay with cycling status messages between sections. Simulates processing time. Define messages per section in a 2D array. Fade each message, then dismiss overlay via callback.
Optional: step actions
String-matched in executeStep(). Actions run before highlighting because elements inside collapsed panels can't be found by querySelector until the panel is open. Adding a new action is one if block. Keep it simple. Common patterns beyond expand/collapse: expandOne (open one panel, close all siblings - accordion style), call (trigger a named function like requestBriefing()), addClass/removeClass (toggle a CSS class on document.body for global state changes).
Gotchas
These are the failure points that come up repeatedly:
- Layout shift: Use
outlinenotborderfor highlighting. Outline does not affect box model. - Hidden elements: If target is inside a collapsed container, the action must open it first. This is why actions execute before highlighting.
- Dynamic selectors: Framework-generated class names (
.css-1a2b3c) break between builds. Usedata-*attributes or IDs. - Scroll conflicts:
scrollIntoView({ block: 'center' })conflicts with fixed headers/panels. Setscroll-padding-bottomon the scroll container to account for the narrator panel height. - Z-index: Narrator panel at 500+, interstitials at 490, highlighted elements at 2+. Check for conflicts with existing modals or dropdowns.
- Cleanup on exit: Reset every piece of state the demo touched: close opened panels, remove highlights, clear timers. Missing cleanup leaves confusing UI state.
- Form inputs: If the page has text inputs, textareas, or selects, the keyboard handler must skip them. Otherwise pressing Space in a text field triggers play/pause instead of typing. Check
e.target.tagNameand bail out forINPUT,TEXTAREA,SELECT. - ES modules: If using
<script type="module">, all demo functions called fromonclickmust be onwindow.*. - Print: Hide demo panel and interstitial in
@media print. - Accessibility: Narrator panel should have
role="status"andaria-live="polite". Highlight outlines must meet contrast requirements. - TTS cancellation leaks: If
speechSynthesis.cancel()is missing from any cleanup path (stop, step, mute, unmount), the previous utterance plays over the new one. Every function that clears timers must also cancel speech. Guard allspeechSynthesiscalls withif ('speechSynthesis' in window). - TTS voices async:
speechSynthesis.getVoices()returns an empty array on first call in some browsers. Always listen for thevoiceschangedevent and cache the result.
Applicability
Works for: prototypes, PoCs, HTML slide decks, data storytelling dashboards, product demos, workshop facilitation, investor pitches, onboarding walkthroughs.
Does not replace: user testing tools, screen recorders, production onboarding tours (use a tour library with persistence and analytics for those).
For framework apps, mount the demo engine as a conditional overlay component and pass the script array as a prop. For single-file demos, inline everything for offline/USB-stick distribution.
Implementation Reference
Copy-paste example code snippets for implementing the guided demo pattern. Adapt colours, selectors, timing etc. to suit the application.
Narrator panel
<div id="demoPanel" class="demo-panel">
<div class="demo-progress"><div id="progressFill" class="demo-progress-fill"></div></div>
<div style="padding:16px 24px;">
<div id="narrator" class="demo-narrator"></div>
</div>
<div class="demo-controls">
<button onclick="stepBack()">«</button>
<button id="playBtn" onclick="togglePlayback()">▶</button>
<button onclick="stepForward()">»</button>
<span style="margin-left:12px;font-size:12px;opacity:0.6;">
<button onclick="setSpeed(0.5)" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.6;font-size:11px;">0.5x</button>
<button onclick="setSpeed(1)" style="background:none;border:none;color:inherit;cursor:pointer;font-size:11px;">1x</button>
<button onclick="setSpeed(2)" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.6;font-size:11px;">2x</button>
</span>
<span style="margin-left:auto;font-size:12px;opacity:0.6;" id="demoCounter">1 / 10</span>
</div>
</div>.demo-panel {
position: fixed; bottom: 0; left: 0; right: 0;
z-index: 500;
background: #1a1a2e; color: white;
transform: translateY(100%);
transition: transform .35s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
}
.demo-panel.open { transform: translateY(0); }
.demo-narrator {
font-size: 18px; line-height: 1.65;
color: rgba(255,255,255,0.9); min-height: 48px;
}
.demo-narrator .typing-cursor {
display: inline;
animation: blink .7s infinite;
color: #4fc3f7;
}
@keyframes blink { 50% { opacity: 0; } }
.demo-progress { height: 3px; background: rgba(255,255,255,0.1); }
.demo-progress-fill { height: 100%; background: #4fc3f7; transition: width .3s ease; }
.demo-controls {
display: flex; align-items: center; gap: 8px;
padding: 6px 24px 14px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.demo-controls button {
background: rgba(255,255,255,0.1); border: none;
color: white; padding: 4px 12px; border-radius: 4px;
cursor: pointer; font-size: 14px;
}
.demo-controls button:hover { background: rgba(255,255,255,0.2); }Adapt: background colour to match the application's branding. The #4fc3f7 accent works on dark backgrounds; swap for the app's accent colour.
---
Highlight class
.demo-highlight {
outline: 2px solid #4fc3f7 !important;
outline-offset: 4px;
border-radius: 6px;
animation: demoGlow 2s ease-in-out infinite;
position: relative;
z-index: 2;
}
@keyframes demoGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba(79,195,247,0); outline-color: #4fc3f7; }
50% { box-shadow: 0 0 16px 4px rgba(79,195,247,0.15); outline-color: rgba(79,195,247,0.7); }
}Why outline not border: outline does not affect the element's box model, so adding or removing the highlight does not cause layout shift.
---
Typewriter function
let typeTimer = null;
const TYPE_SPEED = 5; // ms per character at 1x
const PAUSE_MS = 3000; // ms pause between steps at 1x
let playbackSpeed = 1;
function typeText(text, element, onComplete) {
element.innerHTML = '';
let i = 0;
function tick() {
if (i < text.length) {
const span = document.createElement('span');
span.textContent = text.substring(0, ++i);
element.innerHTML = '';
element.appendChild(span);
const cursor = document.createElement('span');
cursor.className = 'typing-cursor';
cursor.textContent = '|';
element.appendChild(cursor);
typeTimer = setTimeout(tick, TYPE_SPEED / playbackSpeed);
} else {
element.textContent = text;
if (onComplete) onComplete();
}
}
tick();
}Uses textContent (not innerHTML) for the narration text to prevent injection. The innerHTML = '' clearing is intentional and safe (no user content). The timer reference is stored so it can be cancelled on pause, step, or stop.
function setSpeed(s) {
playbackSpeed = s;
document.querySelectorAll('.demo-controls span button').forEach(btn => {
btn.style.opacity = parseFloat(btn.textContent) === s ? '1' : '0.6';
});
}---
Playback loop
let isActive = false;
let isPlaying = false;
let currentStep = 0;
let currentSection = 0;
let pauseTimer = null;
let currentHighlight = null;
function startDemo() {
if (isActive) return;
isActive = true;
isPlaying = true;
currentStep = 0;
document.getElementById('demoPanel').classList.add('open');
updatePlayButton();
// Navigate to first section
switchSection(0);
playStep(0);
}
function playStep(idx) {
currentStep = idx;
const step = DEMO_SCRIPT[idx];
updateProgress();
// Section change
if (step.section !== currentSection) {
if (step.transition) {
showInterstitial(step.section, () => {
switchSection(step.section);
executeStep(step);
});
return;
}
switchSection(step.section);
}
executeStep(step);
}
function executeStep(step) {
// Run action before highlighting
if (step.action) {
executeAction(step.action, step.actionTarget);
}
// Highlight target
clearHighlight();
if (step.target) {
const el = document.querySelector(step.target);
if (el) {
el.classList.add('demo-highlight');
currentHighlight = el;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Narrate
const narrator = document.getElementById('narrator');
const onDone = () => {
if (isPlaying && currentStep < DEMO_SCRIPT.length - 1) {
pauseTimer = setTimeout(() => {
if (isPlaying) playStep(currentStep + 1);
}, PAUSE_MS / playbackSpeed);
}
};
if (isPlaying) {
typeText(step.text, narrator, onDone);
} else {
narrator.textContent = step.text;
}
}
function switchSection(idx) {
currentSection = idx;
// Replace this with your app's navigation logic:
// e.g. show/hide tab panels, call router.push(), set slide index
document.querySelectorAll('.section').forEach((s, i) => {
s.style.display = i === idx ? 'block' : 'none';
});
}The switchSection() function is the main integration point. Replace its body with whatever navigation the application uses (tab switching, route changes, scroll-to-section, slide index).
---
Transition interstitials
<div class="demo-interstitial" id="demoInterstitial">
<div class="demo-interstitial-text" id="interstitialText">Processing...</div>
</div>.demo-interstitial {
position: fixed; inset: 0; z-index: 490;
background: rgba(0,0,0,0.85);
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 16px;
opacity: 0; visibility: hidden;
transition: opacity .3s, visibility .3s;
}
.demo-interstitial.visible { opacity: 1; visibility: visible; }
.demo-interstitial-text {
font-size: 15px; color: rgba(255,255,255,0.7);
font-weight: 500; letter-spacing: .01em;
}function showInterstitial(sectionIdx, onComplete) {
const overlay = document.getElementById('demoInterstitial');
const textEl = document.getElementById('interstitialText');
// Define messages per section
const messages = [
[],
['Analysing data...', 'Running calculations...'],
['Generating recommendations...', 'Scoring confidence...'],
['Preparing summary...', 'Compiling results...']
];
const msgs = messages[sectionIdx] || ['Processing...'];
overlay.classList.add('visible');
let i = 0;
function next() {
if (i < msgs.length) {
textEl.textContent = msgs[i++];
setTimeout(next, 1200);
} else {
overlay.classList.remove('visible');
onComplete();
}
}
next();
}Adapt: interstitial messages should reflect what the application is "doing" between sections. Brand the overlay with a logo or icon if appropriate.
---
Step actions
function executeAction(action, target) {
if (action === 'expand') {
document.getElementById(target).classList.add('open');
}
if (action === 'collapse') {
document.getElementById(target).classList.remove('open');
}
if (action === 'collapseAll') {
document.querySelectorAll('.' + target + '.open').forEach(el => el.classList.remove('open'));
}
if (action === 'click') {
document.getElementById(target).click();
}
if (action === 'expandOne') {
// Accordion: close all siblings, open target
document.querySelectorAll('.' + target.split(':')[0] + '.open').forEach(el => el.classList.remove('open'));
document.getElementById(target.split(':')[1]).classList.add('open');
}
if (action === 'call') {
// Trigger a named function, e.g. actionTarget: 'requestBriefing'
if (typeof window[target] === 'function') window[target]();
}
if (action === 'addClass') {
document.body.classList.add(target);
}
if (action === 'removeClass') {
document.body.classList.remove(target);
}
// Add more as needed - one if block per action type
}Actions run before highlighting because the target element might be inside a collapsed panel. The panel must be open before querySelector can find and scroll to the element.
---
Keyboard controls
document.addEventListener('keydown', (e) => {
if (!isActive) return;
// Don't capture keys when user is interacting with form elements
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.code === 'Space') {
e.preventDefault();
togglePlayback();
} else if (e.code === 'ArrowRight') {
stepForward();
} else if (e.code === 'ArrowLeft') {
stepBack();
} else if (e.code === 'KeyM') {
toggleTTS();
} else if (e.code === 'Escape') {
stopDemo();
}
});
function togglePlayback() {
if (isPlaying) {
isPlaying = false;
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
} else {
isPlaying = true;
playStep(currentStep);
}
updatePlayButton();
}
function stepForward() {
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
isPlaying = false;
updatePlayButton();
if (currentStep < DEMO_SCRIPT.length - 1) playStep(currentStep + 1);
}
function stepBack() {
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
isPlaying = false;
updatePlayButton();
if (currentStep > 0) playStep(currentStep - 1);
}
function updatePlayButton() {
document.getElementById('playBtn').innerHTML = isPlaying ? '▮▮' : '▶';
}The isActive gate prevents keyboard capture when the demo is not running.
---
Progress bar
function updateProgress() {
const pct = ((currentStep + 1) / DEMO_SCRIPT.length * 100);
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('demoCounter').textContent =
(currentStep + 1) + ' / ' + DEMO_SCRIPT.length;
}---
Step countdown indicator
A thin bar that fills during the pause between steps, showing time until auto-advance. Uses CSS animation driven by a dynamically set animation-duration, not JS intervals.
Markup
Add this directly below the step progress bar, above the narrator text:
<div class="demo-step-countdown">
<div id="countdownFill" class="demo-step-countdown-fill"></div>
</div>The container is always rendered (prevents layout shift). The fill element is shown/hidden by adding/removing a class that triggers the animation.
CSS
.demo-step-countdown {
height: 2px;
background: rgba(255, 255, 255, 0.06);
}
.demo-step-countdown-fill {
height: 100%;
width: 0%;
background: rgba(79, 195, 247, 0.7); /* use the app's accent colour */
}
.demo-step-countdown-fill.running {
animation: demoCountdown linear forwards;
}
@keyframes demoCountdown {
from { width: 0%; }
to { width: 100%; }
}Use linear timing, not ease. The viewer reads it as a countdown and easing makes the remaining time harder to judge. The fill colour should be the application's accent colour at 0.6-0.8 opacity.
Starting the countdown
In the onDone callback (fired when typewriter text finishes), before setting the pause timer. The approach: remove and re-add the fill element to reset the CSS animation cleanly.
function startCountdown(durationMs) {
const container = document.getElementById('countdownFill');
// Clone and replace to reset CSS animation
const fresh = container.cloneNode(false);
container.parentNode.replaceChild(fresh, container);
fresh.id = 'countdownFill';
fresh.style.animationDuration = durationMs + 'ms';
fresh.classList.add('running');
}
function clearCountdown() {
const fill = document.getElementById('countdownFill');
if (fill) {
fill.classList.remove('running');
fill.style.width = '0%';
}
}In vanilla JS (no React mount/unmount), replacing the DOM node with a clone is the cleanest way to restart a CSS animation from 0%. The alternative (animation: none; void el.offsetHeight; animation: ...) is a reflow hack that is less reliable.
Integration with executeStep
Update the onDone callback to start the countdown alongside the pause timer:
const onDone = () => {
if (isPlaying && currentStep < DEMO_SCRIPT.length - 1) {
const adjustedPause = (step.delay ?? PAUSE_MS) / playbackSpeed;
startCountdown(adjustedPause);
pauseTimer = setTimeout(() => {
if (isPlaying) playStep(currentStep + 1);
}, adjustedPause);
}
};The countdown only starts when there is a next step to advance to. No countdown on the last step.
Cleanup
Add clearCountdown() inside clearAllTimers(). This single site handles all reset scenarios (stepping, pausing, stopping, exiting) because every interrupting action calls clearAllTimers() first:
function clearAllTimers() {
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
clearCountdown();
if ('speechSynthesis' in window) speechSynthesis.cancel();
}---
Cleanup
function stopDemo() {
isActive = false;
isPlaying = false;
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
document.getElementById('demoPanel').classList.remove('open');
clearHighlight();
document.getElementById('narrator').textContent = '';
// Reset any UI state modified by actions during the demo
document.querySelectorAll('.open').forEach(el => {
// Only close elements that the demo opened - scope this selector
// to your app's collapsible class names
});
}
function clearHighlight() {
if (currentHighlight) {
currentHighlight.classList.remove('demo-highlight');
currentHighlight = null;
}
document.querySelectorAll('.demo-highlight').forEach(el =>
el.classList.remove('demo-highlight')
);
}Cleanup must reset every piece of state the demo touched. Scope the .open cleanup to the application's specific collapsible classes to avoid accidentally closing unrelated UI.
---
Flow diagram
flowchart TD
Start([User clicks Play]) --> Init[Open narrator panel\nReset to step 0]
Init --> PlayStep
PlayStep[playStep idx] --> SectionCheck{Section changed?}
SectionCheck -- No --> Execute
SectionCheck -- Yes --> TransCheck{Has transition?}
TransCheck -- Yes --> Interstitial[Show overlay\nCycle status messages]
TransCheck -- No --> SwitchSection[Switch visible content]
Interstitial --> SwitchSection
SwitchSection --> Execute
Execute[executeStep] --> RunAction{Has action?}
RunAction -- Yes --> DoAction[Run UI mutation]
RunAction -- No --> Highlight
DoAction --> Highlight
Highlight[Add highlight class to target\nscrollIntoView] --> TypeCheck{Auto-playing?}
TypeCheck -- Yes --> Typewriter[Type text char by char]
TypeCheck -- No --> Instant[Show full text]
Typewriter --> Pause[Wait pause duration / speed]
Instant --> WaitInput([Wait for input])
Pause --> LastCheck{Last step?}
LastCheck -- No --> PlayStep
LastCheck -- Yes --> Complete([Done])
WaitInput -- Space --> PlayStep
WaitInput -- Arrow keys --> PlayStep
WaitInput -- Escape --> Cleanup([Reset all state])---
Text-to-speech narration
TTS uses the browser's speechSynthesis Web Speech API. Off by default. The user activates it via a speaker icon in the control bar.
Control bar toggle
Add a mute/unmute button to the narrator panel controls, between the step buttons and speed controls:
<button id="ttsBtn" onclick="toggleTTS()" title="Toggle narration (M)" style="opacity:0.4;">
🔈
</button>The button uses a speaker character entity. Dimmed (opacity: 0.4) when muted (default), full opacity when active.
Voice selection
Voices load asynchronously in some browsers. Listen for the voiceschanged event and cache the selected voice.
let selectedVoice = null;
let isMuted = true;
function initVoices() {
if (!('speechSynthesis' in window)) return;
const voices = speechSynthesis.getVoices();
if (!voices.length) return;
// Preference cascade - adjust locale and voice names for target audience
const cascades = [
// 1. Specific high-quality voice by name and locale
v => v.name.includes('Daniel') && v.lang === 'en-GB',
// 2. Google voices for target locale
v => v.name.includes('Google') && v.lang.startsWith('en-GB'),
// 3. Any voice matching target locale, excluding novelty voices
v => v.lang.startsWith('en-GB') && !/Grandma|Grandpa|Novelty|Bells/i.test(v.name),
// 4. Fallback locales
v => v.lang.startsWith('en-AU'),
v => v.lang.startsWith('en'),
];
for (const predicate of cascades) {
const match = voices.find(predicate);
if (match) { selectedVoice = match; return; }
}
}
// Voices may not be available immediately
if ('speechSynthesis' in window) {
speechSynthesis.addEventListener('voiceschanged', initVoices);
initVoices(); // try immediately in case they're already loaded
}Adapt the preference cascade to the project's target audience locale. The example above prefers British English, falling back through Australian English to any English voice.
speakText function
Called at the start of every step. Cancels any in-progress utterance before speaking.
function speakText(text) {
if (!('speechSynthesis' in window)) return;
speechSynthesis.cancel(); // cut off previous utterance
if (isMuted) return;
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = playbackSpeed;
if (selectedVoice) utterance.voice = selectedVoice;
speechSynthesis.speak(utterance);
}Integration with executeStep
Add a single speakText(step.text) call in executeStep(), after highlighting and before the typewriter/instant-text branch. One call site, not two:
// ... after clearHighlight and scrollIntoView ...
// TTS: speak before typewriter starts
speakText(step.text);
// ... then typewriter or instant text as before ...This placement ensures TTS fires for both auto-play and paused/manual-step code paths.
Toggle and keyboard shortcut
function toggleTTS() {
isMuted = !isMuted;
document.getElementById('ttsBtn').style.opacity = isMuted ? '0.4' : '1';
if (isMuted && 'speechSynthesis' in window) {
speechSynthesis.cancel();
}
}The M shortcut is already included in the keyboard handler (see Keyboard controls section).
Cancellation sites
speechSynthesis.cancel() must appear in every cleanup path. Missing any one of these causes the previous utterance to play over the new step's narration:
function stopDemo() {
isActive = false;
isPlaying = false;
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
if ('speechSynthesis' in window) speechSynthesis.cancel();
// ... rest of cleanup
}
// Also add to clearAllTimers if you have a consolidated timer-clearing function:
function clearAllTimers() {
clearTimeout(typeTimer);
clearTimeout(pauseTimer);
if ('speechSynthesis' in window) speechSynthesis.cancel();
}---
Print styles
@media print {
.demo-panel, .demo-interstitial { display: none !important; }
}Accessibility
<div id="narrator" class="demo-narrator" role="status" aria-live="polite"></div>Add role="status" and aria-live="polite" to the narrator element so screen readers announce the narration text as it changes.
React + React Router Integration Reference
Guidance for implementing the guided demo pattern in React applications with React Router. The vanilla JS patterns from implementation.md need adaptation for React's rendering model.
Architecture decision: Context + Components
Use a React Context provider for the demo engine, not a vanilla JS overlay mounted outside the React tree. The primary reason is that useNavigate() (React Router) is only available inside the router's component tree. A vanilla overlay would need hacks to trigger route changes.
The engine is a hybrid: React manages state and lifecycle, but highlighting and scroll use direct DOM manipulation (classList, scrollIntoView) because the highlight target is any arbitrary element on any page.
File structure
src/demo/
DemoContext.tsx # Engine: state, playback loop, typewriter, navigation
DemoPanel.tsx # Fixed bottom narrator bar with controls
DemoInterstitial.tsx # Full-screen transition overlay
DemoTrigger.tsx # Start/stop button
DemoHighlight.css # Highlight + panel + interstitial styles
demoScript.ts # Step definitions and interstitial messagesProvider placement
The DemoProvider must be inside the router (needs useNavigate) and wrapping the app:
<BrowserRouter>
<AppProvider>
<DemoProvider>
<App />
</DemoProvider>
</AppProvider>
</BrowserRouter>The panel, interstitial, and trigger button render inside the app shell layout alongside the main content.
---
Step definitions use routes, not section indices
In vanilla JS, steps reference section: 0 (an integer index for show/hide). In React Router apps, steps reference route: '/sites/SITE-001' (the path to navigate to).
interface DemoStep {
route: string // React Router path
target: string | null // CSS selector for element to highlight
text: string // Narrator text
transition?: boolean // Show interstitial overlay before navigating
action?: string // Optional UI mutation before highlighting
actionTarget?: string // Selector or ID for the action target
delay?: number // Override pause duration for this step (ms)
}For demos with hardcoded mock data, use literal route strings. For demos against live data where IDs aren't known at script-authoring time, use a resolver function:
interface DemoStep {
route: string | (() => string) // function for dynamic routes
// ...
}
// e.g. { route: () => `/sites/${getFirstSite().id}`, ... }When route is a function, call it at playback time to resolve the path. This keeps the step array declarative while supporting runtime data.
The switchSection() function from the vanilla pattern becomes a navigate() call followed by a render wait.
---
Stale closures: the core React challenge
This is the single trickiest issue. The vanilla JS engine uses mutable variables (let isPlaying = false) that setTimeout callbacks close over. The value is always current because there is only one binding.
In React, useState creates a new value on each render. A setTimeout scheduled during render N captures render N's value. By the time it fires, the component may be on render N+5, but the callback still sees the old value.
Solution: mirror every timer-relevant state value in a ref
const [isPlaying, setIsPlaying] = useState(false)
const isPlayingRef = useRef(false)
// Keep ref in sync
useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying])All timer callbacks (setTimeout in typewriter ticks, pause timers, interstitial cycling) must read from refs, not state:
// WRONG: captures stale isPlaying from the render when setTimeout was scheduled
pauseTimerRef.current = setTimeout(() => {
if (isPlaying) playStep(nextIdx) // isPlaying is stale
}, PAUSE_MS)
// RIGHT: reads the current value at execution time
pauseTimerRef.current = setTimeout(() => {
if (isPlayingRef.current) playStepRef.current(nextIdx)
}, PAUSE_MS / playbackSpeedRef.current)Values that need refs: isPlaying, isActive, currentStep, playbackSpeed, location.pathname, navigate function.
The playStep/executeStep circular dependency
In vanilla JS, playStep calls executeStep, and executeStep's onDone callback calls playStep. This is fine with plain functions.
In React with useCallback, this creates a circular dependency that breaks memoisation (each depends on the other). Solution: store playStep in a ref that is updated every render.
const playStepRef = useRef<(idx: number) => Promise<void>>(async () => {})
// Plain function (not useCallback) - recreated every render, which is intentional
async function playStepImpl(idx: number) {
// ... engine logic using refs for all state reads
}
// Update ref every render so callbacks always call the latest version
playStepRef.current = playStepImpl
// In executeStep's onDone callback:
const onDone = () => {
if (isPlayingRef.current) {
pauseTimerRef.current = setTimeout(() => {
playStepRef.current(stepIdx + 1) // always calls latest playStep
}, PAUSE_MS / playbackSpeedRef.current)
}
}The stable useCallback functions (startDemo, stopDemo, togglePlayback, stepForward, stepBack) call playStepRef.current(...) instead of playStep(...) directly.
---
querySelector timing after route navigation
When navigate('/new-route') is called, React unmounts the old page and mounts the new one asynchronously. querySelector returns null until the new DOM is painted.
Solution: poll with a short timeout after navigation
async function playStep(idx: number) {
const step = DEMO_SCRIPT[idx]
if (locationRef.current !== step.route) {
if (step.transition) await showInterstitial(step.route)
navigateRef.current(step.route)
// Wait for React to render the new page
await new Promise<void>(resolve => {
setTimeout(resolve, 200) // 200ms covers most page renders
})
}
await executeStep(idx)
}For highlighting, use an additional polling loop to find the target element:
function waitForElement(selector: string, maxAttempts = 10): Promise<Element | null> {
return new Promise(resolve => {
let attempts = 0
function poll() {
const el = document.querySelector(selector)
if (el) { resolve(el); return }
if (++attempts >= maxAttempts) { resolve(null); return }
pollTimerRef.current = setTimeout(poll, 50)
}
poll()
})
}50ms interval, 10 attempts = 500ms maximum wait. This is invisible to the user since the typewriter hasn't started yet.
Update locationRef after navigation
The React effect that syncs locationRef from location.pathname hasn't fired yet when playStep continues after navigation. Manually update the ref:
navigateRef.current(step.route)
await new Promise<void>(resolve => {
setTimeout(() => {
locationRef.current = step.route // manual sync
resolve()
}, 200)
})---
Interstitial promise cancellation
The interstitial overlay uses a Promise that resolves when the message cycling timer completes. If the user steps forward or backward during an interstitial, clearAllTimers() kills the cycling timer but the Promise never resolves. The overlay stays visible and playStep hangs on the await.
Solution: dismiss interstitial at the start of every playStep call
async function playStep(idx: number) {
clearAllTimers()
// Force-dismiss any stuck interstitial from a cancelled previous step
setInterstitialVisible(false)
setInterstitialText('')
// ... proceed with step
}This is a no-op when there is no active interstitial, and fixes the stuck overlay when there is.
---
Step actions: the .click() bridge
The vanilla skill's executeAction() uses direct DOM manipulation (classList.add, .click()). In React, calling .click() on a React-rendered element triggers its synthetic event handler, which updates state through React's normal flow. This is the simplest and most reliable way to bridge from the demo engine's DOM world into React state.
For example, clicking a tab button fires its onClick handler which calls setActiveTab() through React's state management. No conflicts with re-rendering occur because the click triggers a state change, which triggers a re-render, which is exactly what you want.
Preferred approaches for triggering React state from the demo engine, in order:
1. `.click()` on the trigger element - simplest, works when there's a clickable element in the DOM. Reach for this first. 2. Custom events - dispatch a CustomEvent on document and have the component listen for it. Keeps the component in control of its own state. Use when there's no clickable trigger element. 3. `useImperativeHandle` with ref - exposes open()/close() methods. Cleanest React pattern but requires modifying the target component, which you want to avoid in a demo overlay.
---
Highlight survival across re-renders
classList.add('demo-highlight') mutates the actual DOM node. React only overwrites DOM attributes it manages. During reconciliation, React uses setAttribute('class', ...) which will overwrite the class list and strip demo-highlight - but only if the component actually re-renders while highlighted.
In practice this rarely happens because highlight durations are short (a few seconds per step) and steps that highlight an element don't typically trigger state changes on that element's component. Navigation to a new route unmounts the old page entirely.
If a highlighted component does re-render frequently (e.g. from a timer or websocket), use a data attribute instead of a class:
// Defensive alternative: survives React re-renders
el.setAttribute('data-demo-highlight', 'true')
// ...
el.removeAttribute('data-demo-highlight')[data-demo-highlight] {
outline: 2px solid #4fc3f7 !important;
outline-offset: 4px;
/* ... same styles as .demo-highlight */
}React doesn't manage arbitrary data attributes set via setAttribute, so they survive reconciliation.
---
Data attributes on React components
The skill recommends data-* attributes for stable selectors. In React, custom components silently swallow unknown props unless they spread ...rest onto a DOM element.
// BROKEN: FilterBar doesn't forward data-demo to the DOM
<FilterBar data-demo="filter-bar" searchValue={search} ... />
// WORKS: wrapper div receives the attribute directly
<div data-demo="filter-bar">
<FilterBar searchValue={search} ... />
</div>Check whether each component forwards arbitrary props before adding data-demo directly. When in doubt, use a wrapper div. The wrapper adds no visual impact and guarantees the attribute reaches the DOM.
Tailwind utility classes (e.g. .grid.grid-cols-3) are stable across builds (unlike CSS Modules hashes), but complex selectors with escaped characters (.sm\\:grid-cols-2) are fragile and hard to read. Prefer data-demo attributes for any element the demo targets.
---
Keyboard handler: skip form inputs
The vanilla skill's keyboard section doesn't mention this. React apps commonly have form inputs on demo-targeted pages. Without this guard, pressing Space while focused on a text input triggers play/pause instead of typing a space character.
function handleKeyDown(e: KeyboardEvent) {
if (!isActiveRef.current) return
// Don't capture keys when user is interacting with form elements
const tag = (e.target as HTMLElement).tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (e.code === 'Space') { e.preventDefault(); togglePlayback() }
else if (e.code === 'ArrowRight') { stepForward() }
else if (e.code === 'ArrowLeft') { stepBack() }
else if (e.code === 'KeyM') { toggleTTS() }
else if (e.code === 'Escape') { stopDemo() }
}---
Typewriter in React: state vs DOM
The vanilla typewriter sets element.textContent directly. In React, use state for the displayed text and a ref for the timer:
// In DemoContext
function typeText(text: string, onComplete?: () => void) {
setCurrentText('')
setShowCursor(true)
let i = 0
function tick() {
if (i < text.length) {
setCurrentText(text.substring(0, ++i))
typeTimerRef.current = setTimeout(tick, TYPE_SPEED / playbackSpeedRef.current)
} else {
setCurrentText(text)
setShowCursor(false)
if (onComplete) onComplete()
}
}
tick()
}
// In DemoPanel component
<div className="demo-narrator" role="status" aria-live="polite">
{currentText}
{showCursor && <span className="typing-cursor">|</span>}
</div>This avoids innerHTML entirely. The cursor is a conditional React element, not an injected DOM node.
---
Cleanup: local vs global state
Add an effect that cleans up if the provider unmounts while the demo is active (e.g. hot module reload during development, or a parent route change):
useEffect(() => {
return () => {
clearAllTimers()
clearHighlight()
document.body.classList.remove('demo-active')
}
}, [clearAllTimers, clearHighlight])Without this, orphaned timers fire against stale state setters, causing React warnings in the console.
What stopDemo needs to restore
stopDemo() resets demo engine state (timers, highlights, demo-active class) and typically navigates to /. Local component state (open panels, selected filters) resets naturally when the component unmounts on navigation.
The exception is global state held in React Context (e.g. an active role, selected theme, or user preference). If any demo action mutated global context state, stopDemo must explicitly restore it. If the demo doesn't navigate away on exit, local component state cleanup becomes the caller's responsibility too.
---
Text-to-speech narration in React
The vanilla TTS pattern from implementation.md needs React-specific handling for state synchronisation and cleanup.
Dual state + ref for isMuted
isMuted needs both a React state (for UI re-renders of the toggle icon) and a ref (for access inside timer callbacks without stale closures). Keep them in sync:
const [isMuted, setIsMuted] = useState(true) // off by default
const isMutedRef = useRef(true)
useEffect(() => { isMutedRef.current = isMuted }, [isMuted])The speakText function reads from isMutedRef (not isMuted state) because it runs inside setTimeout callbacks:
function speakText(text: string) {
if (!('speechSynthesis' in window)) return
speechSynthesis.cancel()
if (isMutedRef.current) return
const utterance = new SpeechSynthesisUtterance(text)
utterance.rate = playbackSpeedRef.current
if (selectedVoiceRef.current) utterance.voice = selectedVoiceRef.current
speechSynthesis.speak(utterance)
}Voice initialisation in useEffect
const selectedVoiceRef = useRef<SpeechSynthesisVoice | null>(null)
useEffect(() => {
if (!('speechSynthesis' in window)) return
function pickVoice() {
const voices = speechSynthesis.getVoices()
if (!voices.length) return
// Adapt cascade to project locale
const cascades: Array<(v: SpeechSynthesisVoice) => boolean> = [
v => v.name.includes('Daniel') && v.lang === 'en-GB',
v => v.name.includes('Google') && v.lang.startsWith('en-GB'),
v => v.lang.startsWith('en-GB') && !/Grandma|Grandpa|Novelty/i.test(v.name),
v => v.lang.startsWith('en-AU'),
v => v.lang.startsWith('en'),
]
for (const predicate of cascades) {
const match = voices.find(predicate)
if (match) { selectedVoiceRef.current = match; return }
}
}
speechSynthesis.addEventListener('voiceschanged', pickVoice)
pickVoice()
return () => speechSynthesis.removeEventListener('voiceschanged', pickVoice)
}, [])Toggle with atomic state + ref update
Use the setState(prev => ...) pattern and update the ref inside the same callback:
const toggleTTS = useCallback(() => {
setIsMuted(prev => {
const next = !prev
isMutedRef.current = next
if (next && 'speechSynthesis' in window) speechSynthesis.cancel()
return next
})
}, [])Cancellation in cleanup
Add speechSynthesis.cancel() to clearAllTimers and the provider's unmount effect:
function clearAllTimers() {
if (typeTimerRef.current) clearTimeout(typeTimerRef.current)
if (pauseTimerRef.current) clearTimeout(pauseTimerRef.current)
if (pollTimerRef.current) clearTimeout(pollTimerRef.current)
if ('speechSynthesis' in window) speechSynthesis.cancel()
}
// In the cleanup useEffect:
useEffect(() => {
return () => {
clearAllTimers()
clearHighlight()
document.body.classList.remove('demo-active')
}
}, [clearAllTimers, clearHighlight])clearAllTimers() already cancels speech, so the unmount effect doesn't need a separate speechSynthesis.cancel() call.
Preserving play state on manual step
stepForward() and stepBack() must NOT set isPlaying to false. They should only clear timers then play the target step. The existing isPlayingRef value determines whether the new step auto-types or shows instantly. Forcing pause on every manual step is a common bug that makes the play button show the wrong state.
const stepForward = useCallback(() => {
clearAllTimers()
// Do NOT call setIsPlaying(false) here
if (currentStepRef.current < DEMO_SCRIPT.length - 1) {
playStepRef.current(currentStepRef.current + 1)
}
}, [clearAllTimers])DemoPanel toggle button
<button
onClick={toggleTTS}
title="Toggle narration (M)"
style={{ opacity: isMuted ? 0.4 : 1, background: 'none', border: 'none', color: 'inherit', cursor: 'pointer', fontSize: '16px' }}
>
{isMuted ? '\u{1F507}' : '\u{1F50A}'}
</button>Place between the step navigation buttons and speed controls.
---
Step countdown indicator in React
The vanilla countdown uses DOM node cloning to reset the CSS animation. In React, the cleaner approach is conditional rendering: mounting a fresh element restarts the animation automatically.
State in DemoContext
const [countdownActive, setCountdownActive] = useState(false)
const [countdownDuration, setCountdownDuration] = useState(0)No refs needed for these - they drive UI rendering only and are never read inside timer callbacks.
Starting the countdown
In the onDone callback (fired when typewriter text finishes), before setting the pause timer:
const onDone = () => {
if (isPlayingRef.current && stepIdx < DEMO_SCRIPT.length - 1) {
const adjustedPause = (step.delay ?? PAUSE_MS) / playbackSpeedRef.current
setCountdownActive(true)
setCountdownDuration(adjustedPause)
pauseTimerRef.current = setTimeout(() => {
playStepRef.current(stepIdx + 1)
}, adjustedPause)
}
}Clearing the countdown
Add setCountdownActive(false) to clearAllTimers() (alongside the existing timer clears and speechSynthesis.cancel() from the TTS section). This single site handles all reset scenarios (stepping, pausing, stopping, exiting):
// Add this line inside the existing clearAllTimers:
setCountdownActive(false)DemoPanel markup
Place directly below the step progress bar, above the narrator text:
<div className="demo-step-countdown">
{countdownActive && (
<div
className="demo-step-countdown-fill"
style={{ animationDuration: `${countdownDuration}ms` }}
/>
)}
</div>The container is always rendered (prevents layout shift). The fill element is conditionally mounted. When countdownActive goes from false to true, React mounts a fresh DOM element, which starts the CSS animation from 0%. When it goes back to false, the element unmounts. This mount/unmount cycle resets the animation cleanly between steps - no need for key props, requestAnimationFrame two-phase tricks, or CSS transition hacks.
CSS (same as vanilla)
.demo-step-countdown {
height: 2px;
background: rgba(255, 255, 255, 0.06);
}
.demo-step-countdown-fill {
height: 100%;
background: rgba(79, 195, 247, 0.7); /* use the app's accent colour */
animation: demoCountdown linear forwards;
}
@keyframes demoCountdown {
from { width: 0%; }
to { width: 100%; }
}Edge cases handled by clearAllTimers
- Pausing mid-countdown: timers cleared, bar disappears. Resuming replays the step which restarts the countdown after text finishes.
- Stepping while paused:
clearAllTimersresets the bar. The paused code path doesn't callonDone, so no new countdown starts. - Last step:
onDoneonly sets the countdown whenstepIdx < script.length - 1, so no bar on the final step. - Speed change mid-countdown: the current bar keeps its original duration. The new speed applies to the next step's countdown.
---
Scroll padding target
The main SKILL.md gotchas mention scroll-padding-bottom. In React app layouts, the scroll container is typically body or html, not the <main> element. Set scroll-padding-bottom on the actual scroll container, and padding-bottom on main to push content above the narrator panel.
---
React StrictMode
In development, React StrictMode double-invokes effects. The keyboard handler effect returns a cleanup function (removes the listener), so double-invocation is handled. The startDemo function should guard against double-start with if (isActiveRef.current) return.