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.
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.
- Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
- Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
- 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.jsongit clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx
npx remotion render <CompositionId> out.mp4claude 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).
// ===== 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>
);
};
- Read the recipePrompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
- Get the codetool: git
git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install - Register + rendertool: remotion
npx remotion render <CompositionId> out.mp4 # component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx - Or re-implementPort the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
| param | type | default | description |
|---|---|---|---|
| — | |||
Mais nesta categoria

Wall reveal: three moves
Three whole-wall batch entrances — bento cell-by-cell light-up, grid wave flip, and blueprint draw-on; all reveal in place with no displacement, complementing the fly-in, displacement-type deck-deal-flyin.

Value-stagger gradient bars
16 bars enter with delay as a time stagger, while height/hue/offset/blur each lay out as a value gradient from first to last; the second beat moves the stagger origin to center and re-lays the pulse peaking there.

SVG shape morph loop
A 140-point closed outline morphs smoothly into another and back; both shapes are resampled to equal point counts in polar coordinates with per-point radius interpolation and inOutCubic, plus a light scale breathing, slow rotation, and hue drifting 185°→305° mid-morph.

Runway ground skim entrance
From a low ground-skim angle, UI cards rain down fast and land with zero bounce, then the whole page stands up and squares to camera.