{
  "slug": "skeleton-reveal",
  "title": "Skeleton reveal",
  "tagline": "Three-tier reveal from sketch to structure to content — hand-drawn doodles get swapped for skeleton bars, which resolve into real UI.",
  "description": "Three-tier reveal from sketch to structure to content — hand-drawn doodles get swapped for skeleton bars, which resolve into real UI.",
  "category": "ui-entrance",
  "tags": [
    "shotcraft",
    "remotion",
    "ui-entrance"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 5.78,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/skeleton-reveal/poster-b394493d3819.jpg",
    "video": "https://gimgs.net/motion/media/skeleton-reveal/video-4c271b777cf5.mp4"
  },
  "variants": [
    {
      "key": "skeleton-reveal",
      "label": "skeleton-reveal",
      "description": "A three-tier reveal from draft to skeleton to content — a hand-drawn doodle placeholder (with a simmering jitter) gets swapped for a gray-bar skeleton window in one beat; after the skeleton list scrolls in, the camera pushes in and the gray bars resolve line by line into avatars and word-by-word text, with the last word landing half a beat late.",
      "use": "",
      "video": "https://gimgs.net/motion/media/skeleton-reveal/video-4c271b777cf5.mp4",
      "poster": "https://gimgs.net/motion/media/skeleton-reveal/poster-b394493d3819.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/skeleton-reveal.html",
  "json_url": "https://gimgs.net/motion/skeleton-reveal.json",
  "template_url": "https://gimgs.net/motion/skeleton-reveal/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/skeleton-reveal.md",
  "updated_at": "2026-09-24T07:40:21.516Z",
  "prompt": "## When to use\n\nA \"from nothing to something\" entrance narrative for product UI; the section where the product interface is first shown after the opening\n\n## Intention\n\nThe UI-entrance library is full of \"the whole page arrives at once\" (fly-in/drop/wipe). This card instead splits the entrance into **three tiers of fidelity**: hand-drawn doodle (idea) → gray-bar skeleton (structure) → real content (product) — each jump is a satisfying beat of \"becoming real,\" and the viewer follows the narrative arc from \"sketch to product.\" The skeleton-to-content reveal borrows loading-state grammar — users see skeleton screens every day, so gray bars instantly read as \"content is coming,\" and that expectation is free. Division of labor with document-typewriter-reveal: that's **one document** being written out block by block (the content is the star, meant to be read); this is **one interface** progressively becoming real (structure is the star, text is the final layer of fidelity). Division of labor with ai-stream-response: that's evidence rows \"flowing in\" one by one (a growing list); this is already-placed placeholders \"developing\" (the layout is set from the start, fidelity is what upgrades).\n\n## Duration & energy\n\n- Duration: ~5.7s (172f: doodle 1s + swap-to-real 0.3s + skeleton scroll-in 1.2s + push-in reveal 3s)\n- Energy: Medium (a narrative-driven entrance; the emphasis is on the two moments of \"becoming real\")\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\nUI 登场库全是\"整页一次到位\"（飞入/落位/擦出）。本卡把登场拆成\n**三级保真度跃迁**：手绘涂鸦（想法）→ 灰条骨架（结构）→ 真实内容\n（产品），每次跃迁都是一拍\"变成真的\"的爽点，观众跟着走完\"从草图\n到产品\"的叙事弧。骨架→内容的显影借的是加载态语法——用户天天见\nskeleton screen，看到灰条就知道\"内容要来了\"，预期免费。与\ndocument-typewriter-reveal 分工：那是**一份文档**被逐块写出来（内容是\n主角、要读字）；本卡是**一个界面**逐级变真（结构是主角、字是最后\n一级质感）。与 ai-stream-response 分工：那是证据行逐条\"汇入\"（列表\n在生长），本卡是已就位的占位\"显影\"（布局早定，保真度在升级）。\n\n## 动效核心\n- 涂鸦 = 种子抖动折线/blob 路径（mulberry32），SVG 圆头粗 stroke\n  （11–14px）；**每 5f 换一套种子做\"煮沸\"**——静止的手绘读作贴图\n- 换真一拍：涂鸦层 8f Easing.in 加速缩退（scale -14%）+ 淡出，\n  骨架窗口同时 spring(damping 16, stiffness 160) 弹入，交叠不留空场\n- 骨架行 translateY 520→0 逐行错峰 6f 滚入（ease-out cubic）\n- 显影 = 骨架/内容双层叠放 opacity 互换 + 头像 scale 0.7→1 长出，\n  伴随镜头 1→1.34 inOut 推近（76f）——推近给显影一个\"凑近看清\"的\n  视线动机\n- 文字逐词 2.5f 步进上浮 14px 进场；末行末词 +14f 晚半拍（\"pink?\"\n  式收尾顿点）\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 煮沸周期 | 每 5f 换种子，抖幅 ±6–10px | <3f 抖成噪点；>8f 读作卡顿而非手绘活性 |\n| 换真一拍 | 涂鸦 8f 加速缩退，骨架 spring 同帧弹入 | 这拍必须快而果断——拖长成 crossfade，\"跃迁\"就没了 |\n| 骨架滚入 | 每行错峰 6f，各 22f ease-out | 骨架期要短暂停留（观众登记\"结构\"）再显影，粘连读不出三级 |\n| 显影节拍 | 每行错峰 13f，行内 12f；镜头推近同段起 | 逐行有节奏地变真；全屏同帧显影读作换图 |\n| 逐词进场 | 2.5f/词，上浮 14px + 淡入 | 词级进场是最后一级质感；整行淡入降一档也成立 |\n| 晚半拍 | 末行末词 +14f | 收尾顿点让\"加载完成\"有句号；全部齐落平淡 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 三级布局必须严格同构：涂鸦的窗口/侧栏/消息行位置要和骨架、骨架要和真内容对得上——错位后\"显影\"变成\"换了个页面\"，叙事断\n- 实战真内容层贴产品真实截图/排版时，骨架灰条的行高与槽位要按截图量（document-typewriter-reveal 卡\"烤入纹理对齐\"同族教训）\n- 涂鸦线稿别画细节：粗圆头 blob + 波浪线足够，画得太像 UI 第一级和第二级就没差了\n- mock 文案合规：不出现客户/成员真名（Q1 通例）\n- 声音：换真一拍钉一声 pop/whoosh，逐行显影各一声极轻 tick，末词晚半拍处配一声轻 chime 收束\n\n## 参考实现\ndemos/ui-entrance/skeleton-reveal/\n（SkeletonReveal.tsx）\n原片出处：slack-promo 4–4.5s + 10–15s\n\n## Reference implementation (Remotion)\n\n- demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx\n\nThe component imports shared fixtures from `demos/_fixtures` and helpers from `assets/lib` in the repo; SKILL.md there documents the render workflow.\n",
  "flow": [
    {
      "step": "Read the recipe",
      "note": "Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points."
    },
    {
      "step": "Get the code",
      "tool": "git",
      "cmd": "git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install"
    },
    {
      "step": "Register + render",
      "tool": "remotion",
      "cmd": "npx remotion render <CompositionId> out.mp4   # component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx"
    },
    {
      "step": "Or re-implement",
      "note": "Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing."
    }
  ],
  "files": [
    {
      "path": "demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx"
    }
  ],
  "contract": "React/Remotion composition (TSX) from the open-source video-shotcraft library (Apache-2.0). It is not a browser-playable template: clone the repo, register the component in a Remotion project and render with `npx remotion render`. The recipe (prompt) describes timing, easing and parameters so you can also re-implement the shot in any engine.",
  "render": {
    "engine": "remotion",
    "frames": 173,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx"
    ],
    "note": "Clone https://github.com/Vincentwei1021/video-shotcraft (npm install). The component lives under demos/{category}/{name}/ and imports shared fixtures from demos/_fixtures and helpers from assets/lib. Copy it (with those imports) into a Remotion project, register a <Composition> with the template's width/height/fps/duration in Root.tsx, then `npx remotion render <CompositionId> out.mp4`. SKILL.md in the repo documents the full Claude Code / Codex workflow. The prompt below is the shot recipe (timing, easing, parameter table, pitfalls) — enough to re-implement the shot in HTML/WAAPI or another engine if you prefer."
  },
  "source": "// ===== demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx =====\n// skeleton-reveal —— slack-promo 4–15s 合并\n// 三级显影：全屏手绘粗笔触涂鸦占位（圆头 blob 线稿，煮沸抖动）→ 一拍内被\n// 灰条骨架 UI 窗口替换 → 骨架消息列表滚入，镜头推近时灰条逐行\"显影\"成\n// 头像+文字内容（最后一行末词晚半拍到）。\nimport React from 'react';\nimport {\n  AbsoluteFill,\n  useCurrentFrame,\n  useVideoConfig,\n  interpolate,\n  spring,\n  Easing,\n} from 'remotion';\n\nconst mulberry32 = (a: number) => () => {\n  let t = (a += 0x6d2b79f5);\n  t = Math.imul(t ^ (t >>> 15), t | 1);\n  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n};\n\nconst INK = '#2f2f2f';\nconst PAPER = '#f2f0ea';\n\n// ——— 手绘涂鸦工具：把折线/圆加种子抖动，画粗圆头 stroke ———\nconst wobbleLine = (\n  x1: number, y1: number, x2: number, y2: number,\n  seed: number, amp = 7, segs = 8,\n) => {\n  const rnd = mulberry32(seed);\n  const pts: string[] = [];\n  for (let i = 0; i <= segs; i++) {\n    const t = i / segs;\n    const jx = (rnd() - 0.5) * amp * 2;\n    const jy = (rnd() - 0.5) * amp * 2;\n    pts.push(`${i === 0 ? 'M' : 'L'} ${x1 + (x2 - x1) * t + jx} ${y1 + (y2 - y1) * t + jy}`);\n  }\n  return pts.join(' ');\n};\n\nconst wobbleBlobRect = (\n  x: number, y: number, w: number, h: number,\n  seed: number, amp = 10, r = 70,\n) => {\n  // 圆头 blob 矩形：四条边中段抖动，四角大圆弧\n  const rnd = mulberry32(seed);\n  const j = () => (rnd() - 0.5) * amp * 2;\n  return [\n    `M ${x + r + j()} ${y + j()}`,\n    `L ${x + w / 2 + j()} ${y + j()}`, `L ${x + w - r + j()} ${y + j()}`,\n    `Q ${x + w + j()} ${y + j()} ${x + w + j()} ${y + r + j()}`,\n    `L ${x + w + j()} ${y + h / 2 + j()}`, `L ${x + w + j()} ${y + h - r + j()}`,\n    `Q ${x + w + j()} ${y + h + j()} ${x + w - r + j()} ${y + h + j()}`,\n    `L ${x + w / 2 + j()} ${y + h + j()}`, `L ${x + r + j()} ${y + h + j()}`,\n    `Q ${x + j()} ${y + h + j()} ${x + j()} ${y + h - r + j()}`,\n    `L ${x + j()} ${y + h / 2 + j()}`, `L ${x + j()} ${y + r + j()}`,\n    `Q ${x + j()} ${y + j()} ${x + r + j()} ${y + j()}`,\n  ].join(' ');\n};\n\nconst wobbleCircle = (cx: number, cy: number, r: number, seed: number, amp = 6) => {\n  const rnd = mulberry32(seed);\n  const n = 14;\n  const pts: string[] = [];\n  for (let i = 0; i <= n; i++) {\n    const a = (i / n) * Math.PI * 2;\n    const rr = r + (rnd() - 0.5) * amp * 2;\n    pts.push(`${i === 0 ? 'M' : 'L'} ${cx + Math.cos(a) * rr} ${cy + Math.sin(a) * rr}`);\n  }\n  return pts.join(' ') + ' Z';\n};\n\nconst Doodle: React.FC<{ boil: number }> = ({ boil }) => {\n  const S = boil * 977; // 每次煮沸换一套抖动种子\n  const stroke = {\n    fill: 'none' as const,\n    stroke: INK,\n    strokeLinecap: 'round' as const,\n    strokeLinejoin: 'round' as const,\n  };\n  return (\n    <svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>\n      {/* 窗口大 blob */}\n      <path d={wobbleBlobRect(250, 140, 1420, 800, S + 1)} {...stroke} strokeWidth={14} />\n      {/* 侧栏分隔 */}\n      <path d={wobbleLine(600, 160, 600, 920, S + 2)} {...stroke} strokeWidth={12} />\n      {/* 侧栏 logo blob + 短线 */}\n      <path d={wobbleCircle(420, 260, 52, S + 3)} {...stroke} strokeWidth={12} />\n      {Array.from({ length: 6 }).map((_, i) => (\n        <path\n          key={`sb${i}`}\n          d={wobbleLine(330, 400 + i * 82, 470 + ((i * 53) % 70), 400 + i * 82, S + 10 + i)}\n          {...stroke}\n          strokeWidth={11}\n        />\n      ))}\n      {/* 主区消息行：圆头像 blob + 波浪文字线 */}\n      {Array.from({ length: 4 }).map((_, i) => {\n        const y = 320 + i * 160;\n        return (\n          <g key={`row${i}`}>\n            <path d={wobbleCircle(720, y, 44, S + 30 + i)} {...stroke} strokeWidth={12} />\n            <path d={wobbleLine(810, y - 28, 1180 + ((i * 97) % 220), y - 28, S + 40 + i)} {...stroke} strokeWidth={11} />\n            <path d={wobbleLine(810, y + 24, 1420 - ((i * 71) % 260), y + 24, S + 50 + i)} {...stroke} strokeWidth={11} />\n          </g>\n        );\n      })}\n    </svg>\n  );\n};\n\n// ——— 骨架/内容消息行 ———\nconst NAMES = ['Ana', 'Ben', 'Kai', 'Mia'];\nconst AVA = ['#5a5a58', '#7a7a78', '#4a4a48', '#8f8f8d'];\nconst MSGS = [\n  'Morning! Kicking off the rebrand today',\n  'Logo drafts are ready for review',\n  'Nice — shipping the deck this afternoon',\n  'Love it. Can we make it pink?',\n];\n\nconst Row: React.FC<{ i: number; dev: number; wordAt: (w: number, n: number) => number }> = ({\n  i, dev, wordAt,\n}) => {\n  const words = MSGS[i].split(' ');\n  return (\n    <div style={{ position: 'relative', height: 96 }}>\n      {/* 骨架层 */}\n      <div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: 1 - dev }}>\n        <div style={{ width: 72, height: 72, borderRadius: 16, background: '#d5d5d3' }} />\n        <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, paddingTop: 6 }}>\n          <div style={{ height: 18, width: 180 + ((i * 67) % 90), background: '#d5d5d3', borderRadius: 9 }} />\n          <div style={{ height: 16, width: `${58 + ((i * 31) % 30)}%`, background: '#e2e2e0', borderRadius: 8 }} />\n        </div>\n      </div>\n      {/* 内容层（逐词显影） */}\n      <div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: dev > 0.02 ? 1 : 0 }}>\n        <div\n          style={{\n            width: 72, height: 72, borderRadius: 16, background: AVA[i],\n            color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',\n            fontSize: 32, fontWeight: 800, opacity: dev,\n            transform: `scale(${0.7 + 0.3 * dev})`,\n          }}\n        >\n          {NAMES[i][0]}\n        </div>\n        <div style={{ flex: 1, paddingTop: 2 }}>\n          <div style={{ fontSize: 26, fontWeight: 800, color: INK, opacity: dev }}>\n            {NAMES[i]}\n            <span style={{ fontWeight: 400, fontSize: 19, color: '#9a9a98', marginLeft: 12 }}>9:0{i + 1} AM</span>\n          </div>\n          <div style={{ fontSize: 27, color: '#3c3c3a', marginTop: 8 }}>\n            {words.map((w, wi) => {\n              const p = wordAt(wi, words.length);\n              return (\n                <span\n                  key={wi}\n                  style={{\n                    display: 'inline-block',\n                    marginRight: 9,\n                    opacity: p,\n                    transform: `translateY(${(1 - p) * 14}px)`,\n                  }}\n                >\n                  {w}\n                </span>\n              );\n            })}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport const SkeletonReveal: React.FC = () => {\n  const f = useCurrentFrame();\n  const { fps } = useVideoConfig();\n\n  const SWAP = 32; // 涂鸦 → 骨架的那一拍\n\n  // 涂鸦：煮沸抖动 + 一拍内被拉回替换（快速缩退 + 淡出，加速离场）\n  const boil = Math.floor(f / 5);\n  const doodleOut = interpolate(f, [SWAP, SWAP + 8], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.in(Easing.cubic),\n  });\n  const doodleVisible = f < SWAP + 9;\n\n  // 骨架窗口：swap 时弹入\n  const winIn = spring({ frame: f - SWAP, fps, config: { damping: 16, stiffness: 160, mass: 0.7 } });\n\n  // 骨架列表滚入（第二拍）\n  const rowSlide = (i: number) =>\n    interpolate(f, [SWAP + 12 + i * 6, SWAP + 34 + i * 6], [520, 0], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n      easing: Easing.out(Easing.cubic),\n    });\n\n  // 镜头推近\n  const zoom = interpolate(f, [66, 142], [1, 1.34], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.inOut(Easing.cubic),\n  });\n\n  // 逐行显影\n  const devAt = (i: number) =>\n    interpolate(f, [80 + i * 13, 92 + i * 13], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n      easing: Easing.out(Easing.quad),\n    });\n\n  // 逐词进场；最后一行最后一个词晚半拍（+14 帧）\n  const wordAt = (row: number) => (w: number, n: number) => {\n    const isLastWordOfLastRow = row === 3 && w === n - 1;\n    const start = 82 + row * 13 + w * 2.5 + (isLastWordOfLastRow ? 14 : 0);\n    return interpolate(f, [start, start + 9], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n      easing: Easing.out(Easing.cubic),\n    });\n  };\n\n  return (\n    <AbsoluteFill style={{ background: PAPER, fontFamily: 'Helvetica, Arial, sans-serif', overflow: 'hidden' }}>\n      {/* 第三/二拍：骨架 UI 窗口 */}\n      {f >= SWAP && (\n        <AbsoluteFill\n          style={{\n            transform: `scale(${zoom})`,\n            transformOrigin: '58% 46%',\n          }}\n        >\n          <div\n            style={{\n              position: 'absolute', left: 250, top: 140, width: 1420, height: 800,\n              background: '#ffffff', border: '2px solid #d8d8d6', borderRadius: 22,\n              overflow: 'hidden', display: 'flex',\n              boxShadow: '0 12px 40px rgba(0,0,0,0.10)',\n              opacity: Math.min(1, winIn * 2),\n              transform: `scale(${interpolate(winIn, [0, 1], [1.08, 1])})`,\n            }}\n          >\n            {/* 侧栏 */}\n            <div style={{ width: 300, background: '#3a3a3a', padding: '30px 26px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 22 }}>\n              <div style={{ width: 46, height: 46, borderRadius: 12, background: '#777775' }} />\n              {Array.from({ length: 7 }).map((_, i) => (\n                <div key={i} style={{ height: 13, width: `${55 + ((i * 37) % 40)}%`, background: '#5a5a58', borderRadius: 7 }} />\n              ))}\n            </div>\n            {/* 主区 */}\n            <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>\n              <div style={{ height: 72, borderBottom: '2px solid #e4e4e2', display: 'flex', alignItems: 'center', padding: '0 34px' }}>\n                <div style={{ height: 18, width: 230, background: '#d5d5d3', borderRadius: 9 }} />\n              </div>\n              <div style={{ flex: 1, padding: '30px 40px', display: 'flex', flexDirection: 'column', gap: 32, overflow: 'hidden' }}>\n                {Array.from({ length: 4 }).map((_, i) => (\n                  <div key={i} style={{ transform: `translateY(${rowSlide(i)}px)`, opacity: rowSlide(i) > 500 ? 0 : 1 }}>\n                    <Row i={i} dev={devAt(i)} wordAt={wordAt(i)} />\n                  </div>\n                ))}\n              </div>\n            </div>\n          </div>\n        </AbsoluteFill>\n      )}\n\n      {/* 第一拍：手绘涂鸦占位（在骨架之上，一拍内缩退让位） */}\n      {doodleVisible && (\n        <AbsoluteFill\n          style={{\n            background: PAPER,\n            opacity: 1 - doodleOut,\n            transform: `scale(${1 - doodleOut * 0.14})`,\n            transformOrigin: '50% 50%',\n          }}\n        >\n          <Doodle boil={boil} />\n        </AbsoluteFill>\n      )}\n    </AbsoluteFill>\n  );\n};\n\n\n"
}