---
id: daily-ui-demo
name: "ui-demo"
url: https://skills.yangsir.net/skill/daily-ui-demo
author: affaan-m
domain: multimedia
tags: ["video-editing", "demo-creation", "playwright", "screen-recording", "product-demo"]
install_count: 4500
rating: 4.40 (8 reviews)
github: https://github.com/affaan-m/everything-claude-code
---

# ui-demo

> 使用Playwright自动录制Web应用演示视频，添加光标叠加和流畅节奏，支持产品展示和教程制作

**Stats**: 4,500 installs · 4.4/5 (8 reviews)

## Before / After 对比

### 演示视频制作

**Before**:

手动操作录制屏幕、添加光标效果、剪辑视频、配音讲解，一个3分钟演示需要2小时制作，效果不稳定

**After**:

自动化脚本录制产品交互流程，自动添加光标动画和转场效果，20分钟生成专业演示视频，支持多语言配音

| Metric | Before | After | Change |
|---|---|---|---|
| 制作时间 | 120分钟 | 20分钟 | -83% |

## Readme

# ui-demo

# UI Demo Video Recorder

Record polished demo videos of web applications using Playwright's video recording with an injected cursor overlay, natural pacing, and storytelling flow.

## When to Use

- User asks for a "demo video", "screen recording", "walkthrough", or "tutorial"

- User wants to showcase a feature or workflow visually

- User needs a video for documentation, onboarding, or stakeholder presentation

## Three-Phase Process

Every demo goes through three phases: **Discover -> Rehearse -> Record**. Never skip straight to recording.

## Phase 1: Discover

Before writing any script, explore the target pages to understand what is actually there.

### Why

You cannot script what you have not seen. Fields may be `<input>` not `<textarea>`, dropdowns may be custom components not `<select>`, and comment boxes may support `@mentions` or `#tags`. Assumptions break recordings silently.

### How

Navigate to each page in the flow and dump its interactive elements:

```
// Run this for each page in the flow BEFORE writing the demo script
const fields = await page.evaluate(() => {
  const els = [];
  document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
    if (el.offsetParent !== null) {
      els.push({
        tag: el.tagName,
        type: el.type || '',
        name: el.name || '',
        placeholder: el.placeholder || '',
        text: el.textContent?.trim().substring(0, 40) || '',
        contentEditable: el.contentEditable === 'true',
        role: el.getAttribute('role') || '',
      });
    }
  });
  return els;
});
console.log(JSON.stringify(fields, null, 2));

```

### What to look for

- **Form fields**: Are they `<select>`, `<input>`, custom dropdowns, or comboboxes?

- **Select options**: Dump option values AND text. Placeholders often have `value="0"` or `value=""` which looks non-empty. Use `Array.from(el.options).map(o => ({ value: o.value, text: o.text }))`. Skip options where text includes "Select" or value is `"0"`.

- **Rich text**: Does the comment box support `@mentions`, `#tags`, markdown, or emoji? Check placeholder text.

- **Required fields**: Which fields block form submission? Check `required`, `*` in labels, and try submitting empty to see validation errors.

- **Dynamic content**: Do fields appear after other fields are filled?

- **Button labels**: Exact text such as `"Submit"`, `"Submit Request"`, or `"Send"`.

- **Table column headers**: For table-driven modals, map each `input[type="number"]` to its column header instead of assuming all numeric inputs mean the same thing.

### Output

A field map for each page, used to write correct selectors in the script. Example:

```
/purchase-requests/new:
  - Budget Code: <select> (first select on page, 4 options)
  - Desired Delivery: <input type="date">
  - Context: <textarea> (not input)
  - BOM table: inline-editable cells with span.cursor-pointer -> input pattern
  - Submit: <button> text="Submit"

/purchase-requests/N (detail):
  - Comment: <input placeholder="Type a message..."> supports @user and #PR tags
  - Send: <button> text="Send" (disabled until input has content)

```

## Phase 2: Rehearse

Run through all steps without recording. Verify every selector resolves.

### Why

Silent selector failures are the main reason demo recordings break. Rehearsal catches them before you waste a recording.

### How

Use `ensureVisible`, a wrapper that logs and fails loudly:

```
async function ensureVisible(page, locator, label) {
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    const msg = `REHEARSAL FAIL: "${label}" not found - selector: ${typeof locator === 'string' ? locator : '(locator object)'}`;
    console.error(msg);
    const found = await page.evaluate(() => {
      return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
        .filter(el => el.offsetParent !== null)
        .map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
        .join('\n  ');
    });
    console.error('  Visible elements:\n  ' + found);
    return false;
  }
  console.log(`REHEARSAL OK: "${label}"`);
  return true;
}

```

### Rehearsal script structure

```
const steps = [
  { label: 'Login email field', selector: '#email' },
  { label: 'Login submit', selector: 'button[type="submit"]' },
  { label: 'New Request button', selector: 'button:has-text("New Request")' },
  { label: 'Budget Code select', selector: 'select' },
  { label: 'Delivery date', selector: 'input[type="date"]:visible' },
  { label: 'Description field', selector: 'textarea:visible' },
  { label: 'Add Item button', selector: 'button:has-text("Add Item")' },
  { label: 'Submit button', selector: 'button:has-text("Submit")' },
];

let allOk = true;
for (const step of steps) {
  if (!await ensureVisible(page, step.selector, step.label)) {
    allOk = false;
  }
}
if (!allOk) {
  console.error('REHEARSAL FAILED - fix selectors before recording');
  process.exit(1);
}
console.log('REHEARSAL PASSED - all selectors verified');

```

### When rehearsal fails

- Read the visible-element dump.

- Find the correct selector.

- Update the script.

- Re-run rehearsal.

- Only proceed when every selector passes.

## Phase 3: Record

Only after discovery and rehearsal pass should you create the recording.

### Recording Principles

#### 1. Storytelling Flow

Plan the video as a story. Follow user-specified order, or use this default:

- **Entry**: Login or navigate to the starting point

- **Context**: Pan the surroundings so viewers orient themselves

- **Action**: Perform the main workflow steps

- **Variation**: Show a secondary feature such as settings, theme, or localization

- **Result**: Show the outcome, confirmation, or new state

#### 2. Pacing

- After login: `4s`

- After navigation: `3s`

- After clicking a button: `2s`

- Between major steps: `1.5-2s`

- After the final action: `3s`

- Typing delay: `25-40ms` per character

#### 3. Cursor Overlay

Inject an SVG arrow cursor that follows mouse movements:

```
async function injectCursor(page) {
  await page.evaluate(() => {
    if (document.getElementById('demo-cursor')) return;
    const cursor = document.createElement('div');
    cursor.id = 'demo-cursor';
    cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
      <path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
    </svg>`;
    cursor.style.cssText = `
      position: fixed; z-index: 999999; pointer-events: none;
      width: 24px; height: 24px;
      transition: left 0.1s, top 0.1s;
      filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
    `;
    cursor.style.left = '0px';
    cursor.style.top = '0px';
    document.body.appendChild(cursor);
    document.addEventListener('mousemove', (e) => {
      cursor.style.left = e.clientX + 'px';
      cursor.style.top = e.clientY + 'px';
    });
  });
}

```

Call `injectCursor(page)` after every page navigation because the overlay is destroyed on navigate.

#### 4. Mouse Movement

Never teleport the cursor. Move to the target before clicking:

```
async function moveAndClick(page, locator, label, opts = {}) {
  const { postClickDelay = 800, ...clickOpts } = opts;
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    console.error(`WARNING: moveAndClick skipped - "${label}" not visible`);
    return false;
  }
  try {
    await el.scrollIntoViewIfNeeded();
    await page.waitForTimeout(300);
    const box = await el.boundingBox();
    if (box) {
      await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
      await page.waitForTimeout(400);
    }
    await el.click(clickOpts);
  } catch (e) {
    console.error(`WARNING: moveAndClick failed on "${label}": ${e.message}`);
    return false;
  }
  await page.waitForTimeout(postClickDelay);
  return true;
}

```

Every call should include a descriptive `label` for debugging.

#### 5. Typing

Type visibly, not instant-fill:

```
async function typeSlowly(page, locator, text, label, charDelay = 35) {
  const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
  const visible = await el.isVisible().catch(() => false);
  if (!visible) {
    console.error(`WARNING: typeSlowly skipped - "${label}" not visible`);
    return false;
  }
  await moveAndClick(page, el, label);
  await el.fill('');
  await el.pressSequentially(text, { delay: charDelay });
  await page.waitForTimeout(500);
  return true;
}

```

#### 6. Scrolling

Use smooth scroll instead of jumps:

```
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);

```

#### 7. Dashboard Panning

When showing a dashboard or overview page, move the cursor across key elements:

```
async function panElements(page, selector, maxCount = 6) {
  const elements = await page.locator(selector).all();
  for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
    try {
      const box = await elements[i].boundingBox();
      if (box && box.y < 700) {
        await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
        await page.waitForTimeout(600);
      }
    } catch (e) {
      console.warn(`WARNING: panElements skipped element ${i} (selector: "${selector}"): ${e.message}`);
    }
  }
}

```

#### 8. Subtitles

Inject a subtitle bar at the bottom of the viewport:

```
async function injectSubtitleBar(page) {
  await page.evaluate(() => {
    if (document.getElementById('demo-subtitle')) return;
    const bar = document.createElement('div');
    bar.id = 'demo-subtitle';
    bar.style.cssText = `
      position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
      text-align: center; padding: 12px 24px;
      background: rgba(0, 0, 0, 0.75);
      color: white; font-family: -apple-system, "Segoe UI", sans-serif;
      font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
      transition: opacity 0.3s;
      pointer-events: none;
    `;
    bar.textContent = '';
    bar.style.opacity = '0';
    document.body.appendChild(bar);
  });
}

async function showSubtitle(page, text) {
  await page.evaluate((t) => {
    const bar = document.getElementById('demo-subtitle');
    if (!bar) return;
    if (t) {
      bar.textContent = t;
      bar.style.opacity = '1';
    } else {
      bar.style.opacity = '0';
    }
  }, text);
  if (text) await page.waitForTimeout(800);
}

```

Call `injectSubtitleBar(page)` alongside `injectCursor(page)` after every navigation.

Usage pattern:

```
await showSubtitle(page, 'Step 1 - Logging in');
await showSubtitle(page, 'Step 2 - Dashboard overview');
await showSubtitle(page, '');

```

Guidelines:

- Keep subtitle text short, ideally under 60 characters.

- Use `Step N - Action` format for consistency.

- Clear the subtitle during long pauses where the UI can speak for itself.

## Script Template

```
'use strict';
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');

const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
const VIDEO_DIR = path.join(__dirname, 'screenshots');
const OUTPUT_NAME = 'demo-FEATURE.webm';
const REHEARSAL = process.argv.includes('--rehearse');

// Paste injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible, and panElements here.

(async () => {
  const browser = await chromium.launch({ headless: true });

  if (REHEARSAL) {
    const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
    const page = await context.newPage();
    // Navigate through the flow and run ensureVisible for each selector.
    await browser.close();
    return;
  }

  const context = await browser.newContext({
    recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
    viewport: { width: 1280, height: 720 }
  });
  const page = await context.newPage();

  try {
    await injectCursor(page);
    await injectSubtitleBar(page);

    await showSubtitle(page, 'Step 1 - Logging in');
    // login actions

    await page.goto(`${BASE_URL}/dashboard`);
    await injectCursor(page);
    await injectSubtitleBar(page);
    await showSubtitle(page, 'Step 2 - Dashboard overview');
    // pan dashboard

    await showSubtitle(page, 'Step 3 - Main workflow');
    // action sequence

    await showSubtitle(page, 'Step 4 - Result');
    // final reveal
    await showSubtitle(page, '');
  } catch (err) {
    console.error('DEMO ERROR:', err.message);
  } finally {
    await context.close();
    const video = page.video();
    if (video) {
      const src = await video.path();
      const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
      try {
        fs.copyFileSync(src, dest);
        console.log('Video saved:', dest);
      } catch (e) {
        console.error('ERROR: Failed to copy video:', e.message);
        console.error('  Source:', src);
        console.error('  Destination:', dest);
      }
    }
    await browser.close();
  }
})();

```

Usage:

```
# Phase 2: Rehearse
node demo-script.cjs --rehearse

# Phase 3: Record
node demo-script.cjs

```

## Checklist Before Recording

-  Discovery phase completed

-  Rehearsal passes with all selectors OK

-  Headless mode enabled

-  Resolution set to `1280x720`

-  Cursor and subtitle overlays re-injected after every navigation

-  `showSubtitle(page, 'Step N - ...')` used at major transitions

-  `moveAndClick` used for all clicks with descriptive labels

-  `typeSlowly` used for visible input

-  No silent catches; helpers log warnings

-  Smooth scrolling used for content reveal

-  Key pauses are visible to a human viewer

-  Flow matches the requested story order

-  Script reflects the actual UI discovered in phase 1

## Common Pitfalls

- Cursor disappears after navigation - re-inject it.

- Video is too fast - add pauses.

- Cursor is a dot instead of an arrow - use the SVG overlay.

- Cursor teleports - move before clicking.

- Select dropdowns look wrong - show the move, then pick the option.

- Modals feel abrupt - add a read pause before confirming.

- Video file path is random - copy it to a stable output name.

- Selector failures are swallowed - never use silent catch blocks.

- Field types were assumed - discover them first.

- Features were assumed - inspect the actual UI before scripting.

- Placeholder select values look real - watch for `"0"` and `"Select..."`.

- Popups create separate videos - capture popup pages explicitly and merge later if needed.

Weekly Installs569Repository[affaan-m/everyt…ude-code](https://github.com/affaan-m/everything-claude-code)GitHub Stars152.8KFirst Seen12 days agoSecurity Audits[Gen Agent Trust HubPass](/affaan-m/everything-claude-code/ui-demo/security/agent-trust-hub)[SocketPass](/affaan-m/everything-claude-code/ui-demo/security/socket)[SnykWarn](/affaan-m/everything-claude-code/ui-demo/security/snyk)Installed oncodex531opencode507antigravity502gemini-cli502cursor502kimi-cli501

---
*Source: https://skills.yangsir.net/skill/daily-ui-demo*
*Markdown mirror: https://skills.yangsir.net/api/skill/daily-ui-demo/markdown*