Motion · Entrada de UI

Skeleton reveal

Three-tier reveal from sketch to structure to content — hand-drawn doodles get swapped for skeleton bars, which resolve into real UI.

1920×108016:930 fps5.78s · 173 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#ui-entrance

Fonte: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composição Remotion (React/TSX) — vídeo de prévia, sem player no navegador

When to use

A "from nothing to something" entrance narrative for product UI; the section where the product interface is first shown after the opening

Intention

The 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).

Duration & energy

  • Duration: ~5.7s (172f: doodle 1s + swap-to-real 0.3s + skeleton scroll-in 1.2s + push-in reveal 3s)
  • Energy: Medium (a narrative-driven entrance; the emphasis is on the two moments of "becoming real")

Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)

意图

UI 登场库全是"整页一次到位"(飞入/落位/擦出)。本卡把登场拆成 三级保真度跃迁:手绘涂鸦(想法)→ 灰条骨架(结构)→ 真实内容 (产品),每次跃迁都是一拍"变成真的"的爽点,观众跟着走完"从草图 到产品"的叙事弧。骨架→内容的显影借的是加载态语法——用户天天见 skeleton screen,看到灰条就知道"内容要来了",预期免费。与 document-typewriter-reveal 分工:那是一份文档被逐块写出来(内容是 主角、要读字);本卡是一个界面逐级变真(结构是主角、字是最后 一级质感)。与 ai-stream-response 分工:那是证据行逐条"汇入"(列表 在生长),本卡是已就位的占位"显影"(布局早定,保真度在升级)。

动效核心

  • 涂鸦 = 种子抖动折线/blob 路径(mulberry32),SVG 圆头粗 stroke

(11–14px);每 5f 换一套种子做"煮沸"——静止的手绘读作贴图

  • 换真一拍:涂鸦层 8f Easing.in 加速缩退(scale -14%)+ 淡出,

骨架窗口同时 spring(damping 16, stiffness 160) 弹入,交叠不留空场

  • 骨架行 translateY 520→0 逐行错峰 6f 滚入(ease-out cubic)
  • 显影 = 骨架/内容双层叠放 opacity 互换 + 头像 scale 0.7→1 长出,

伴随镜头 1→1.34 inOut 推近(76f)——推近给显影一个"凑近看清"的 视线动机

  • 文字逐词 2.5f 步进上浮 14px 进场;末行末词 +14f 晚半拍("pink?"

式收尾顿点)

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 煮沸周期 | 每 5f 换种子,抖幅 ±6–10px | <3f 抖成噪点;>8f 读作卡顿而非手绘活性 | | 换真一拍 | 涂鸦 8f 加速缩退,骨架 spring 同帧弹入 | 这拍必须快而果断——拖长成 crossfade,"跃迁"就没了 | | 骨架滚入 | 每行错峰 6f,各 22f ease-out | 骨架期要短暂停留(观众登记"结构")再显影,粘连读不出三级 | | 显影节拍 | 每行错峰 13f,行内 12f;镜头推近同段起 | 逐行有节奏地变真;全屏同帧显影读作换图 | | 逐词进场 | 2.5f/词,上浮 14px + 淡入 | 词级进场是最后一级质感;整行淡入降一档也成立 | | 晚半拍 | 末行末词 +14f | 收尾顿点让"加载完成"有句号;全部齐落平淡 |

已知坑

  • demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿,

首次实战须以真实素材回验

  • 三级布局必须严格同构:涂鸦的窗口/侧栏/消息行位置要和骨架、骨架要和真内容对得上——错位后"显影"变成"换了个页面",叙事断
  • 实战真内容层贴产品真实截图/排版时,骨架灰条的行高与槽位要按截图量(document-typewriter-reveal 卡"烤入纹理对齐"同族教训)
  • 涂鸦线稿别画细节:粗圆头 blob + 波浪线足够,画得太像 UI 第一级和第二级就没差了
  • mock 文案合规:不出现客户/成员真名(Q1 通例)
  • 声音:换真一拍钉一声 pop/whoosh,逐行显影各一声极轻 tick,末词晚半拍处配一声轻 chime 收束

参考实现

demos/ui-entrance/skeleton-reveal/ (SkeletonReveal.tsx) 原片出处:slack-promo 4–4.5s + 10–15s

Reference implementation (Remotion)

  • demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx

The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.

Tudo nesta página também é legível por máquina. Aponte seu agente para o JSON ou adicione o servidor MCP do gimgs e peça para buscar templates de motion.

  1. Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
  2. Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
  3. Registre o componente como <Composition> em um projeto Remotion e renderize com `npx remotion render`, ou reimplemente a receita no seu próprio engine.
curl -s https://gimgs.net/motion/skeleton-reveal.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("skeleton-reveal")

Prompt

Use the video-shotcraft shot recipe "Skeleton reveal" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/skeleton-reveal.json
Raw TSX: https://gimgs.net/motion/skeleton-reveal/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 5.78s

1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls).
2. Adapt it for: <describe what you want>.
3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).

JSON · Markdown · llms.txt

10,424 chars · Apache-2.0Abrir template bruto ↗
// ===== demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx =====
// skeleton-reveal —— slack-promo 4–15s 合并
// 三级显影:全屏手绘粗笔触涂鸦占位(圆头 blob 线稿,煮沸抖动)→ 一拍内被
// 灰条骨架 UI 窗口替换 → 骨架消息列表滚入,镜头推近时灰条逐行"显影"成
// 头像+文字内容(最后一行末词晚半拍到)。
import React from 'react';
import {
  AbsoluteFill,
  useCurrentFrame,
  useVideoConfig,
  interpolate,
  spring,
  Easing,
} from 'remotion';

const mulberry32 = (a: number) => () => {
  let t = (a += 0x6d2b79f5);
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

const INK = '#2f2f2f';
const PAPER = '#f2f0ea';

// ——— 手绘涂鸦工具:把折线/圆加种子抖动,画粗圆头 stroke ———
const wobbleLine = (
  x1: number, y1: number, x2: number, y2: number,
  seed: number, amp = 7, segs = 8,
) => {
  const rnd = mulberry32(seed);
  const pts: string[] = [];
  for (let i = 0; i <= segs; i++) {
    const t = i / segs;
    const jx = (rnd() - 0.5) * amp * 2;
    const jy = (rnd() - 0.5) * amp * 2;
    pts.push(`${i === 0 ? 'M' : 'L'} ${x1 + (x2 - x1) * t + jx} ${y1 + (y2 - y1) * t + jy}`);
  }
  return pts.join(' ');
};

const wobbleBlobRect = (
  x: number, y: number, w: number, h: number,
  seed: number, amp = 10, r = 70,
) => {
  // 圆头 blob 矩形:四条边中段抖动,四角大圆弧
  const rnd = mulberry32(seed);
  const j = () => (rnd() - 0.5) * amp * 2;
  return [
    `M ${x + r + j()} ${y + j()}`,
    `L ${x + w / 2 + j()} ${y + j()}`, `L ${x + w - r + j()} ${y + j()}`,
    `Q ${x + w + j()} ${y + j()} ${x + w + j()} ${y + r + j()}`,
    `L ${x + w + j()} ${y + h / 2 + j()}`, `L ${x + w + j()} ${y + h - r + j()}`,
    `Q ${x + w + j()} ${y + h + j()} ${x + w - r + j()} ${y + h + j()}`,
    `L ${x + w / 2 + j()} ${y + h + j()}`, `L ${x + r + j()} ${y + h + j()}`,
    `Q ${x + j()} ${y + h + j()} ${x + j()} ${y + h - r + j()}`,
    `L ${x + j()} ${y + h / 2 + j()}`, `L ${x + j()} ${y + r + j()}`,
    `Q ${x + j()} ${y + j()} ${x + r + j()} ${y + j()}`,
  ].join(' ');
};

const wobbleCircle = (cx: number, cy: number, r: number, seed: number, amp = 6) => {
  const rnd = mulberry32(seed);
  const n = 14;
  const pts: string[] = [];
  for (let i = 0; i <= n; i++) {
    const a = (i / n) * Math.PI * 2;
    const rr = r + (rnd() - 0.5) * amp * 2;
    pts.push(`${i === 0 ? 'M' : 'L'} ${cx + Math.cos(a) * rr} ${cy + Math.sin(a) * rr}`);
  }
  return pts.join(' ') + ' Z';
};

const Doodle: React.FC<{ boil: number }> = ({ boil }) => {
  const S = boil * 977; // 每次煮沸换一套抖动种子
  const stroke = {
    fill: 'none' as const,
    stroke: INK,
    strokeLinecap: 'round' as const,
    strokeLinejoin: 'round' as const,
  };
  return (
    <svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
      {/* 窗口大 blob */}
      <path d={wobbleBlobRect(250, 140, 1420, 800, S + 1)} {...stroke} strokeWidth={14} />
      {/* 侧栏分隔 */}
      <path d={wobbleLine(600, 160, 600, 920, S + 2)} {...stroke} strokeWidth={12} />
      {/* 侧栏 logo blob + 短线 */}
      <path d={wobbleCircle(420, 260, 52, S + 3)} {...stroke} strokeWidth={12} />
      {Array.from({ length: 6 }).map((_, i) => (
        <path
          key={`sb${i}`}
          d={wobbleLine(330, 400 + i * 82, 470 + ((i * 53) % 70), 400 + i * 82, S + 10 + i)}
          {...stroke}
          strokeWidth={11}
        />
      ))}
      {/* 主区消息行:圆头像 blob + 波浪文字线 */}
      {Array.from({ length: 4 }).map((_, i) => {
        const y = 320 + i * 160;
        return (
          <g key={`row${i}`}>
            <path d={wobbleCircle(720, y, 44, S + 30 + i)} {...stroke} strokeWidth={12} />
            <path d={wobbleLine(810, y - 28, 1180 + ((i * 97) % 220), y - 28, S + 40 + i)} {...stroke} strokeWidth={11} />
            <path d={wobbleLine(810, y + 24, 1420 - ((i * 71) % 260), y + 24, S + 50 + i)} {...stroke} strokeWidth={11} />
          </g>
        );
      })}
    </svg>
  );
};

// ——— 骨架/内容消息行 ———
const NAMES = ['Ana', 'Ben', 'Kai', 'Mia'];
const AVA = ['#5a5a58', '#7a7a78', '#4a4a48', '#8f8f8d'];
const MSGS = [
  'Morning! Kicking off the rebrand today',
  'Logo drafts are ready for review',
  'Nice — shipping the deck this afternoon',
  'Love it. Can we make it pink?',
];

const Row: React.FC<{ i: number; dev: number; wordAt: (w: number, n: number) => number }> = ({
  i, dev, wordAt,
}) => {
  const words = MSGS[i].split(' ');
  return (
    <div style={{ position: 'relative', height: 96 }}>
      {/* 骨架层 */}
      <div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: 1 - dev }}>
        <div style={{ width: 72, height: 72, borderRadius: 16, background: '#d5d5d3' }} />
        <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, paddingTop: 6 }}>
          <div style={{ height: 18, width: 180 + ((i * 67) % 90), background: '#d5d5d3', borderRadius: 9 }} />
          <div style={{ height: 16, width: `${58 + ((i * 31) % 30)}%`, background: '#e2e2e0', borderRadius: 8 }} />
        </div>
      </div>
      {/* 内容层(逐词显影) */}
      <div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: dev > 0.02 ? 1 : 0 }}>
        <div
          style={{
            width: 72, height: 72, borderRadius: 16, background: AVA[i],
            color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 32, fontWeight: 800, opacity: dev,
            transform: `scale(${0.7 + 0.3 * dev})`,
          }}
        >
          {NAMES[i][0]}
        </div>
        <div style={{ flex: 1, paddingTop: 2 }}>
          <div style={{ fontSize: 26, fontWeight: 800, color: INK, opacity: dev }}>
            {NAMES[i]}
            <span style={{ fontWeight: 400, fontSize: 19, color: '#9a9a98', marginLeft: 12 }}>9:0{i + 1} AM</span>
          </div>
          <div style={{ fontSize: 27, color: '#3c3c3a', marginTop: 8 }}>
            {words.map((w, wi) => {
              const p = wordAt(wi, words.length);
              return (
                <span
                  key={wi}
                  style={{
                    display: 'inline-block',
                    marginRight: 9,
                    opacity: p,
                    transform: `translateY(${(1 - p) * 14}px)`,
                  }}
                >
                  {w}
                </span>
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
};

export const SkeletonReveal: React.FC = () => {
  const f = useCurrentFrame();
  const { fps } = useVideoConfig();

  const SWAP = 32; // 涂鸦 → 骨架的那一拍

  // 涂鸦:煮沸抖动 + 一拍内被拉回替换(快速缩退 + 淡出,加速离场)
  const boil = Math.floor(f / 5);
  const doodleOut = interpolate(f, [SWAP, SWAP + 8], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.in(Easing.cubic),
  });
  const doodleVisible = f < SWAP + 9;

  // 骨架窗口:swap 时弹入
  const winIn = spring({ frame: f - SWAP, fps, config: { damping: 16, stiffness: 160, mass: 0.7 } });

  // 骨架列表滚入(第二拍)
  const rowSlide = (i: number) =>
    interpolate(f, [SWAP + 12 + i * 6, SWAP + 34 + i * 6], [520, 0], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      easing: Easing.out(Easing.cubic),
    });

  // 镜头推近
  const zoom = interpolate(f, [66, 142], [1, 1.34], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.inOut(Easing.cubic),
  });

  // 逐行显影
  const devAt = (i: number) =>
    interpolate(f, [80 + i * 13, 92 + i * 13], [0, 1], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      easing: Easing.out(Easing.quad),
    });

  // 逐词进场;最后一行最后一个词晚半拍(+14 帧)
  const wordAt = (row: number) => (w: number, n: number) => {
    const isLastWordOfLastRow = row === 3 && w === n - 1;
    const start = 82 + row * 13 + w * 2.5 + (isLastWordOfLastRow ? 14 : 0);
    return interpolate(f, [start, start + 9], [0, 1], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      easing: Easing.out(Easing.cubic),
    });
  };

  return (
    <AbsoluteFill style={{ background: PAPER, fontFamily: 'Helvetica, Arial, sans-serif', overflow: 'hidden' }}>
      {/* 第三/二拍:骨架 UI 窗口 */}
      {f >= SWAP && (
        <AbsoluteFill
          style={{
            transform: `scale(${zoom})`,
            transformOrigin: '58% 46%',
          }}
        >
          <div
            style={{
              position: 'absolute', left: 250, top: 140, width: 1420, height: 800,
              background: '#ffffff', border: '2px solid #d8d8d6', borderRadius: 22,
              overflow: 'hidden', display: 'flex',
              boxShadow: '0 12px 40px rgba(0,0,0,0.10)',
              opacity: Math.min(1, winIn * 2),
              transform: `scale(${interpolate(winIn, [0, 1], [1.08, 1])})`,
            }}
          >
            {/* 侧栏 */}
            <div style={{ width: 300, background: '#3a3a3a', padding: '30px 26px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 22 }}>
              <div style={{ width: 46, height: 46, borderRadius: 12, background: '#777775' }} />
              {Array.from({ length: 7 }).map((_, i) => (
                <div key={i} style={{ height: 13, width: `${55 + ((i * 37) % 40)}%`, background: '#5a5a58', borderRadius: 7 }} />
              ))}
            </div>
            {/* 主区 */}
            <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
              <div style={{ height: 72, borderBottom: '2px solid #e4e4e2', display: 'flex', alignItems: 'center', padding: '0 34px' }}>
                <div style={{ height: 18, width: 230, background: '#d5d5d3', borderRadius: 9 }} />
              </div>
              <div style={{ flex: 1, padding: '30px 40px', display: 'flex', flexDirection: 'column', gap: 32, overflow: 'hidden' }}>
                {Array.from({ length: 4 }).map((_, i) => (
                  <div key={i} style={{ transform: `translateY(${rowSlide(i)}px)`, opacity: rowSlide(i) > 500 ? 0 : 1 }}>
                    <Row i={i} dev={devAt(i)} wordAt={wordAt(i)} />
                  </div>
                ))}
              </div>
            </div>
          </div>
        </AbsoluteFill>
      )}

      {/* 第一拍:手绘涂鸦占位(在骨架之上,一拍内缩退让位) */}
      {doodleVisible && (
        <AbsoluteFill
          style={{
            background: PAPER,
            opacity: 1 - doodleOut,
            transform: `scale(${1 - doodleOut * 0.14})`,
            transformOrigin: '50% 50%',
          }}
        >
          <Doodle boil={boil} />
        </AbsoluteFill>
      )}
    </AbsoluteFill>
  );
};


  1. Read the recipe
    Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
  2. Get the code
    tool: git
    git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install
  3. Register + render
    tool: remotion
    npx remotion render <CompositionId> out.mp4 # component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx
  4. Or re-implement
    Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
paramtypedefaultdescription
—

Mais nesta categoria