Motion · Outro

Strip-away outro

A subtraction outro — after clicking Publish, the editor UI evaporates in staggered layers from the outside in, leaving only the clicked button, which slides to center screen and enlarges before fading to the closing wordmark.

1920×108016:930 fps4.39s · 132 framesremotionLicense: Apache-2.0#shotcraft#remotion#outro

Source: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Remotion composition (React/TSX) — preview video, no in-browser player

When to use

An outro for "publish/done" meaning; a product close that says "after one click, all complexity disappears"

Intention

The library's outros are all "addition": outro-group-photo-launch has elements flying in for a group photo, logo-sting settles and adds an easter egg. This card is the only "subtraction" route — a click detonates the whole UI's exit, leaving only the semantic focal point (the clicked button) on a black screen, translating "publish = complexity goes to zero" into motion language. There are two linchpins: the evaporation must have order — one layer every 4f from the outside in, each layer carrying its own directional displacement (each scattering off toward the edges); out-of-order or same-frame vanishing reads as a power failure, not a publish. The button is the sole survivor and must migrate — during the evaporation it slides from its toolbar corner to screen center and enlarges 1.5x, its white glow intensifying as the black grows, completing its promotion from "UI control" to "ceremonial lead," then finally fading to hand the black screen to the wordmark.

Duration & energy

  • Duration: ~4.3s (130f: cursor in place 34f → evaporation ~40f → button alone → wordmark takes over)
  • Energy: Medium (a quiet opening beat, a sustained exit in the middle, no sudden impact)

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

意图

库内收尾全是"加法":outro-group-photo-launch 元素飞来合影,logo sting 是定住再彩蛋。本卡是唯一的"减法"路线——点击引爆全 UI 退场, 黑场只留语义焦点(那颗被点的按钮),把"发布 = 复杂性归零"翻译成 动效叙事。命门有二:蒸发要有秩序——从外围到中心每 4f 一层、 每层带方向性位移(各自往画外散),乱序或同帧齐灭读作断电故障; 按钮是唯一幸存者且必须迁移——蒸发期间从工具条角落滑到屏心 放大 1.5 倍、白晕随黑场增强,完成从"UI 控件"到"仪式主角"的升格, 最后淡出把黑场交给字标。

动效核心

  • 6 个蒸发层:侧栏→属性面板→画布卡→工具条两端→画布底→工具条中段,

点击后 +4f 起每 4f 一层;每层 STRIP_DUR 14f,Easing.in(quad) 离场加速,fade + 方向性位移(侧栏 −140,0 / 面板 −90,20 / 工具条两端 ∓80,−60 / 画布底 0,40 / 工具条中段 0,−50)

  • 画布内 4 张卡再各错峰 3f,位移左右交替(±70px)——大块整体消失

会读作图层被删,碎开走才是"蒸发"

  • 背景压黑:随画布底层蒸发(+20f 起 20f)灰底 → #111110
  • 按钮:点击 spring(damping 12, stiffness 220) 压缩脉冲;蒸发期

位置/尺寸插值滑向屏心(18f,inOut cubic),scale ×1.5,白晕 30→70px 随黑场增强;+52f 起 12f 淡出

  • 字标接棒:+62f spring(damping 14, stiffness 90) 淡入 +

86%→100% 缩放,黑场定版

  • 光标点击后 10f 内淡出——主角换人,光标必须退场

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 层间错峰 | 4f × 6 层 | 同帧齐灭读作断电;>8f 拖成慢拆迁,仪式感散 | | 单层时长 | 14f,ease-in 加速 | ease-out 离场读作"飘走"不读作"蒸发";>20f 黏 | | 蒸发顺序 | 外围 → 中心,按钮所在层最后 | 中心先空读作故障;按钮层先走全卡崩塌 | | 位移幅度 | 50–140px,方向各自向画外 | 无位移纯 fade 读作调透明度;同方向读作整屏平移 | | 按钮迁移 | 18f 滑到屏心 + scale ×1.5 + 晕光增强 | 不迁移则黑场上一颗角落小钮,读作漏删;先迁后灭顺序不可反 | | 字标接棒 | 按钮淡出后 ~10f 进,spring 定版 | 与按钮同屏抢戏;黑场空窗 >20f 像放完了 |

已知坑

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

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

  • 与 outro-group-photo-launch 分工:全家福是"加法"峰值终镜(多功能

产品全员谢幕),本卡是"减法"收尾(单一动作语义、越走越静)——按 产品气质二选一,不可同片双收尾

  • 与 edit-hook-moves B(logo-sting-button)衔接:本卡字标定版后可接

sting 彩蛋,但 sting 的"突插"会打破减法的静——接不接看全片性格

  • 蒸发的每层必须是语义完整的 UI 区块(侧栏/面板/工具条),按

像素区域切层会在错峰时露出结构不合理的残缺态

  • 按钮迁移全程 zIndex 压最顶——任何蒸发层从按钮前面飘过都是穿帮
  • 触发动作必须是"终结性"语义(Publish/Ship/Done);拿 Save 或普通

确认触发全 UI 蒸发,语义配不上排场

  • 实战按钮用品牌高亮色(原片是 Framer 蓝),demo 的白钮只是灰阶占位

——黑场上按钮与晕光的对比是主角光环,颜色弱则升格失败

参考实现

demos/outro/ui-strip-away-outro/ (UiStripAwayOutro.tsx) 原片出处:framer-ai 33–36.5s

Reference implementation (Remotion)

  • demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx

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

Everything on this page is also machine-readable. Point your agent at the JSON, or add the gimgs MCP server and ask it to search motion templates.

  1. Fetch the JSON — it holds the shot recipe (timing, easing, parameter table, pitfalls) and the full TSX source.
  2. Clone the video-shotcraft repo; the component imports shared fixtures from demos/_fixtures and helpers from assets/lib.
  3. Register the component as a <Composition> in a Remotion project and render with `npx remotion render`, or re-implement the recipe in your own engine.
curl -s https://gimgs.net/motion/ui-strip-away-outro.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("ui-strip-away-outro")

Prompt

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

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

9,220 chars · Apache-2.0Open raw template ↗
// ===== demos/outro/ui-strip-away-outro/UiStripAwayOutro.tsx =====
// ui-strip-away-outro —— framer-ai 33–36.5s
// 满屏灰阶"编辑器"里光标点击高亮 Publish 按钮 → UI 层层错峰蒸发退场
// (每层 fade + 轻微位移,从外围到中心)→ 黑场只剩按钮 → 按钮淡出交棒字标。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion';
import { G, Card } from '../../_fixtures/Fixtures';

const CLICK = 34; // 点击时刻
// 蒸发层级(点击后延迟,外围先走)
const STRIP = {
  sidebar: CLICK + 4,
  leftPanel: CLICK + 8,
  canvasCards: CLICK + 12,
  topbarEnds: CLICK + 16,
  canvasBg: CLICK + 20,
  toolbarShell: CLICK + 24,
};
const STRIP_DUR = 14;
const BTN_FADE = CLICK + 52;
const LOGO_IN = CLICK + 62;

// 某层的蒸发进度 → {opacity, offset}
const useStrip = (frame: number, start: number, dx: number, dy: number) => {
  const p = interpolate(frame, [start, start + STRIP_DUR], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.in(Easing.quad), // 离场加速
  });
  return {
    opacity: 1 - p,
    transform: `translate(${dx * p}px, ${dy * p}px)`,
  };
};

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

  // 背景:编辑器灰底 → 黑场(随 canvasBg 层蒸发压黑)
  const bgDark = interpolate(frame, [STRIP.canvasBg, STRIP.canvasBg + STRIP_DUR + 6], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.inOut(Easing.quad),
  });

  const sidebar = useStrip(frame, STRIP.sidebar, -140, 0);
  const leftPanel = useStrip(frame, STRIP.leftPanel, -90, 20);
  const topLeft = useStrip(frame, STRIP.topbarEnds, -80, -60);
  const topRight = useStrip(frame, STRIP.topbarEnds, 80, -60);
  const toolbarShell = useStrip(frame, STRIP.toolbarShell, 0, -50);
  const canvasFrame = useStrip(frame, STRIP.canvasBg, 0, 40);

  // 画布卡片错峰蒸发
  const cardStrip = (i: number) => useStripStatic(frame, STRIP.canvasCards + i * 3, (i % 2 ? 70 : -70), 50 + i * 10);

  // 按钮:点击脉冲 + 最后淡出
  const press = spring({ frame: frame - CLICK, fps, config: { damping: 12, stiffness: 220 } });
  const pressScale = frame < CLICK ? 1 : 1 - 0.12 * Math.sin(Math.min(1, press) * Math.PI);
  const btnOp = interpolate(frame, [BTN_FADE, BTN_FADE + 12], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  // 蒸发期间按钮从工具条位置滑向屏幕中心,独占黑场
  const btnCenter = interpolate(frame, [STRIP.toolbarShell, STRIP.toolbarShell + 18], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.inOut(Easing.cubic),
  });
  const btnX = 1560 + (960 - 88 - 1560) * btnCenter;
  const btnY = 30 + (540 - 30 - 30) * btnCenter;
  const btnScale = 1 + 0.5 * btnCenter;

  // 字标接棒
  const logoP = spring({ frame: frame - LOGO_IN, fps, config: { damping: 14, stiffness: 90 } });

  // 光标移向按钮
  const curX = interpolate(frame, [4, CLICK - 2], [820, 1636], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad),
  });
  const curY = interpolate(frame, [4, CLICK - 2], [640, 64], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad),
  });
  const curOp = interpolate(frame, [CLICK + 6, CLICK + 16], [1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ background: '#111110', overflow: 'hidden' }}>
      {/* 编辑器灰底(作为一层可蒸发的背景) */}
      <AbsoluteFill style={{ background: G.bg, opacity: 1 - bgDark }} />

      {/* 左侧栏(图层面板) */}
      <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 240, background: G.side, padding: '90px 24px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 20, ...sidebar }}>
        {Array.from({ length: 9 }).map((_, i) => (
          <div key={i} style={{ height: 13, width: `${55 + ((i * 31) % 40)}%`, background: G.sideBar, borderRadius: 6 }} />
        ))}
      </div>

      {/* 右侧属性面板 */}
      <div style={{ position: 'absolute', right: 0, top: 60, bottom: 0, width: 300, background: G.panel, borderLeft: `2px solid ${G.line}`, padding: 28, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 18, ...leftPanel }}>
        {Array.from({ length: 4 }).map((_, i) => (
          <React.Fragment key={i}>
            <div style={{ height: 12, width: '45%', background: G.bar, borderRadius: 6 }} />
            <div style={{ height: 34, background: '#fff', border: `2px solid ${G.line}`, borderRadius: 8, boxSizing: 'border-box' }} />
          </React.Fragment>
        ))}
      </div>

      {/* 顶部工具条左半(logo + 工具 chips) */}
      <div style={{ position: 'absolute', left: 0, top: 0, width: 760, height: 60, background: G.panel, borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', gap: 16, padding: '0 24px', boxSizing: 'border-box', ...topLeft }}>
        <div style={{ width: 32, height: 32, borderRadius: 8, background: G.mid }} />
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} style={{ width: 30, height: 30, borderRadius: 8, background: G.line }} />
        ))}
      </div>
      {/* 顶部工具条中段(标题) */}
      <div style={{ position: 'absolute', left: 760, top: 0, right: 400, height: 60, background: G.panel, borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', justifyContent: 'center', boxSizing: 'border-box', ...toolbarShell }}>
        <div style={{ height: 14, width: 220, background: G.bar, borderRadius: 7 }} />
      </div>
      {/* 顶部工具条右段底板(Invite 假按钮;Publish 单独渲染在最上层) */}
      <div style={{ position: 'absolute', right: 0, top: 0, width: 400, height: 60, background: G.panel, borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', justifyContent: 'flex-start', gap: 14, padding: '0 24px', boxSizing: 'border-box', ...topRight }}>
        <div style={{ height: 36, width: 100, borderRadius: 18, border: `2px solid ${G.bar}`, boxSizing: 'border-box' }} />
      </div>

      {/* 画布区:一张浏览器式大卡 + 两张小卡 */}
      <div style={{ position: 'absolute', left: 320, top: 130, width: 1180, height: 850, ...canvasFrame }}>
        <div style={{ position: 'absolute', inset: 0, background: G.card, border: `2px solid ${G.border}`, borderRadius: 18, boxShadow: '0 10px 40px rgba(0,0,0,0.08)' }}>
          <div style={{ height: 46, borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', gap: 8, padding: '0 18px' }}>
            {[0, 1, 2].map((i) => (
              <div key={i} style={{ width: 14, height: 14, borderRadius: 7, background: G.line }} />
            ))}
            <div style={{ marginLeft: 16, height: 20, width: 380, background: G.bg, borderRadius: 10 }} />
          </div>
        </div>
        {[0, 1, 2, 3].map((i) => {
          const s = cardStrip(i);
          return (
            <div key={i} style={{ position: 'absolute', left: 70 + (i % 2) * 560, top: 120 + Math.floor(i / 2) * 340, ...s }}>
              <Card w={480} h={280} seed={i + 2} />
            </div>
          );
        })}
      </div>

      {/* Publish 按钮(高亮层,最后退场) */}
      <div
        style={{
          position: 'absolute',
          left: btnX,
          top: btnY,
          width: 176,
          height: 44,
          opacity: btnOp,
          transform: `scale(${pressScale})`,
          zIndex: 30,
        }}
      >
        <div
          style={{
            width: 176 * btnScale,
            height: 44 * btnScale,
            marginLeft: -((176 * btnScale - 176) / 2),
            marginTop: -((44 * btnScale - 44) / 2),
            borderRadius: 22 * btnScale,
            background: '#f2f2f0',
            boxShadow: `0 0 ${30 + 40 * btnCenter}px rgba(255,255,255,${0.25 + 0.3 * btnCenter * bgDark})`,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            fontFamily: 'Helvetica, Arial, sans-serif',
            fontWeight: 700,
            fontSize: 20 * btnScale,
            color: '#161615',
          }}
        >
          Publish
        </div>
      </div>

      {/* 字标接棒 */}
      {frame >= LOGO_IN && (
        <AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center' }}>
          <div
            style={{
              opacity: logoP,
              transform: `scale(${0.86 + 0.14 * logoP})`,
              fontFamily: 'Helvetica, Arial, sans-serif',
              fontWeight: 800,
              fontSize: 110,
              letterSpacing: 6,
              color: '#f2f2f0',
            }}
          >
            WORDMARK
          </div>
        </AbsoluteFill>
      )}

      {/* 光标 */}
      <svg width={40} height={44} viewBox="0 0 20 22" style={{ position: 'absolute', left: curX, top: curY, opacity: curOp, zIndex: 40 }}>
        <path d="M2 1 L2 17 L6.5 13.2 L9.4 20 L12.4 18.7 L9.5 12 L15 11.6 Z" fill={G.ink} stroke="#fff" strokeWidth="1.4" />
      </svg>
    </AbsoluteFill>
  );
};

// hook 规则外的静态版本(供 map 内调用)
const useStripStatic = (frame: number, start: number, dx: number, dy: number) => {
  const p = interpolate(frame, [start, start + STRIP_DUR], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.in(Easing.quad),
  });
  return {
    opacity: 1 - p,
    transform: `translate(${dx * p}px, ${dy * p}px)`,
  };
};


  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/outro/ui-strip-away-outro/UiStripAwayOutro.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
—

More in this category