Motion · UI entrance

Product card progressive assemble

A detail card self-assembles field by field — image, title, breadcrumb pill pop in, the old price strikes through, a new price springs in.

1920×108016:930 fps5.5s · 165 framesremotionLicense: Apache-2.0#shotcraft#remotion#ui-entrance

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

When to use

A capability showcase for a product/listing detail page; the hero shot for "structured extraction," "auto-fill," or "data growing itself" narratives

Intention

A detail card that just fades in reads to the viewer as "there's a card." Landing it field by field turns the card into a form being filled out live: each field lands on its own, and the viewer's attention is walked through the card's information structure — where's the image, where's the title, where's the price, where's the key line of copy. The price strikethrough downgrade is the film's only semantic event (not an "appearance" but a "change"), so it's placed at the most prominent point, mid-sequence.

Duration & energy

  • Duration: About 5.0s (150f@30fps)
  • Energy: Medium (dense, continuous small events with no single burst; a slow push sustains the sense of progress)

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

意图

一张详情卡直接淡入,观众只看到"有一张卡"。逐字段落位则把卡变成一份正在被填写的 表单:每个字段各自到位,观众的注意力被牵着走一遍卡的信息结构——图在哪、标题在哪、 价格在哪、正文重点在哪。价格划线降级是全片唯一的语义事件(不是"出现"而是"改变"), 所以它被安排在中段最显眼的位置。

动效核心

  • 字段时间表用"配方帧"书写:F = f => f/60,把 60 分母的帧号折成 t。图 f=0、标题 f=4、

三枚 pill f=8/10/12、价格行 f=16、三行正文 f=30/32/34、三块色卡 f=40/42/44 —— 相邻字段间隔只有 2 帧,是"抓取"的密度而非"逐条展示"的密度

  • 每个字段共享同一条落位曲线:seg(t, t0, t0+0.1, E.outCubic),两种落位模式——

rise 是 translateY(6→0)(图/标题/价格行/正文行),pop 是 scale(lerp(E.outBack(k), 0.4, 1))(pill 与色卡);小件用 pop 大件用 rise

  • 价格降级发生在 f=26(配方帧),是硬切:oldP 同帧换色(#17181c→#9a9da6)、

加 line-through、字号 21px→15px 三件事一起发生;紧接 seg(t, F(26), +0.12) 让 强调色新价用 E.spring(nk, 0.35) 从 1.15 收到 1

  • 马克笔高亮:两个高亮片段各有一层绝对定位的 ACCENT_SOFT 底块,`transform-origin:

left center + scaleX(0→1),窗口是 F(30+li*2+4)` 起、行程 0.085(≈5f)——比字段 落位还快,读作"笔一划过去"

  • 整卡呼吸:seg(t, 0, 0.75, E.outQuad) 驱动 scale(1→1.06),缓起缓收摊在前 75%——

这层运动与所有字段无关,作用是让密集的小事件之间画面仍在推进

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 字段时间表 | 配方帧 0/4/8/10/12/16/30/32/34/40/42/44(F(f)=f/60) | 同类字段间隔 2 帧、跨类间隔 4–14 帧——间隔的疏密就是信息分组;全部改成等间隔会读作机械点名 | | 单字段行程 | 0.1(≈15f) | 行程远大于间隔 → 同时有五六个字段在动,画面是"涌"而非"点";压到 0.04 以下会变成逐个硬闪 | | rise 位移 | 6px | 只有 6px,字段是"沉下去一点点";>20px 会与整卡前推的方向感打架 | | pop 起始缩放 | 0.4 + E.outBack | 0.4 是明确的"从无到有";pill 与色卡尺寸小,过冲不会撞到邻居,大件用 pop 就会显得廉价 | | 价格降级点 | 配方帧 26(t≈0.433) | 安排在价格行落位(f=16)之后 10 帧——必须留出"先看清原价"的时间,紧贴落位会读作从来就是打折价 | | 新价 spring | E.spring(nk, 0.35),1.15→1,窗 0.12 | 全片唯一的过冲量级事件;阻尼调小会抖两下,与旁边的划线动作抢注意力 | | 高亮刷过 | 窗 0.085(≈5f),scaleX 从左 | 5f 是马克笔速度;>0.15 读作背景色渐变而不是划线 | | 整卡前推 | scale 1→1.06,窗 0→0.75,outQuad | 6% 摊在 3.75s 上是纯呼吸;>12% 卡会撞到画框,且 4.5% 的 padding 内的文字会被推出边界 |

已知坑

  • 时间表用的是 60 分母的"配方帧",而实际时长 5000ms = 150f@30fps。这两套帧号不通约:

想按 30fps 的帧数排事件,必须先把配方帧换算成 t 再对照(f=44 → t≈0.733 → 110f@30fps)

  • 最后一个字段在 t≈0.733 落位,之后 1.3s 只有整卡前推(且前推在 t=0.75 停止)——

末段有约 1.2s 的完全静止定格。这是给观众通读整卡的时间,别当成 bug 砍掉

  • 三行正文用 white-space: nowrap 写死不换行,卡宽 78%。换更长文案或中文时会溢出

被 overflow 裁掉,落地必须重新配字号与行长

  • 所有内容都是占位("Sample Product Title"、$249/$189、"Placeholder copy for…"、

三个纯色色卡),字段结构本身才是可复用的部分。换成真实商品数据后字段数量往往变化, 时间表要按新的字段分组重排间隔

  • 高亮块的 t0 = F(30+li*2+4) 比所在行的落位(F(30+li*2))晚 4 配方帧,两者是耦合

的偏移量;改行落位时间必须同步改高亮,否则出现"高亮先到、文字后来"

  • ACCENT / ACCENT_SOFT 是全卡唯一色相来源(新价文字 + 两块高亮),换品牌色改这

两个变量即可;其余全是中性灰阶

参考实现

demos/ui-entrance/product-card-progressive-assemble/ (ProductCardProgressiveAssemble.tsx)

Reference implementation (Remotion)

  • demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.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/product-card-progressive-assemble.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("product-card-progressive-assemble")

Prompt

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

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

6,692 chars · Apache-2.0Open raw template ↗
// ===== demos/ui-entrance/product-card-progressive-assemble/ProductCardProgressiveAssemble.tsx =====
// product-card-progressive-assemble — Progressive Assemble 字段逐个落位(motion-lab 定稿转原生 Remotion)
// 详情卡像被逐字段抓取般自建:图→标题→breadcrumb pill 依次 pop→价格出现后被划线降级、
// 强调色新价 spring 跳出→正文逐行揭示+强调色高亮块由左向右刷过→色卡点亮。
// 整卡极慢 scale 前推保持呼吸。设计坐标 480×270(DesignStage 等比放大)。
import React from 'react';
import { DesignStage, E, lerp, seg, useT } from '../../_fixtures/Motion';

export const PRODUCT_CARD_PROGRESSIVE_ASSEMBLE_DURATION = 150; // 5000ms @30fps

const ACCENT = '#ff6a1f';
const ACCENT_SOFT = 'rgba(255,122,26,.42)';

const F = (f: number) => f / 60; // recipe 帧 → t

// 字段落位样式:rise = 上移 6px 淡入;pop = outBack 从 0.4 弹到 1
const fieldStyle = (t: number, f0: number, mode: 'rise' | 'pop' = 'rise'): React.CSSProperties => {
  const k = seg(t, F(f0), F(f0) + 0.1, E.outCubic);
  return {
    opacity: Math.min(1, k * 2),
    transform:
      mode === 'pop'
        ? `scale(${lerp(E.outBack(Math.min(1, k)), 0.4, 1)})`
        : `translateY(${lerp(k, 6, 0)}px)`,
  };
};

// 描述行:[文本, 是否马克笔高亮]
const LINES: [string, boolean?][][] = [
  [['Placeholder copy for '], ['a key highlight', true], [' in the']],
  [['product body. '], ['Second highlight', true], [' sits here on']],
  [['the third line of neutral sample text.']],
];

export const ProductCardProgressiveAssemble: React.FC = () => {
  const t = useT();
  // 整卡呼吸前推
  const push = seg(t, 0, 0.75, E.outQuad);
  // 价格降级(f=26):旧价划线缩小变灰,新价 spring 跳出
  const cut = t >= F(26);
  const nk = seg(t, F(26), F(26) + 0.12);
  return (
    <DesignStage bg="#0d0e13" raster="zoom">
      <div
        style={{
          position: 'absolute',
          left: '50%',
          top: '50%',
          width: '78%',
          height: '74%',
          transform: `translate(-50%,-50%) scale(${lerp(push, 1, 1.06)})`,
          background: '#f6f5f2',
          borderRadius: 12,
          boxShadow: '0 18px 50px rgba(0,0,0,.5)',
          fontFamily: '-apple-system,Helvetica,sans-serif',
          display: 'flex',
          padding: '4.5%',
          boxSizing: 'border-box',
          gap: '5%',
        }}
      >
        {/* 左:商品图占位(f=0 rise 落位) */}
        <div
          style={{
            flex: '0 0 38%',
            background: '#e2e0da',
            borderRadius: 8,
            position: 'relative',
            overflow: 'hidden',
            ...fieldStyle(t, 0),
          }}
        >
          <div
            style={{
              position: 'absolute',
              left: '18%',
              top: '14%',
              width: '64%',
              height: '72%',
              background: 'linear-gradient(160deg,#2b3040,#171a24)',
              borderRadius: '40% 40% 14% 14%/26% 26% 10% 10%',
            }}
          >
            {/* 拉链 */}
            <div style={{ position: 'absolute', left: '49%', top: '20%', width: '2%', height: '58%', background: '#525a70' }} />
          </div>
        </div>
        {/* 右列 */}
        <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
          {/* 标题(f=4) */}
          <div style={{ fontSize: 19, fontWeight: 800, color: '#17181c', letterSpacing: '-.3px', ...fieldStyle(t, 4) }}>
            Sample Product Title
          </div>
          {/* breadcrumb pills(f=8 起每 2 帧一个 pop) */}
          <div style={{ display: 'flex', gap: 6, margin: '8px 0 10px' }}>
            {['Category', 'Subgroup', 'Detail'].map((txt, i) => (
              <div
                key={txt}
                style={{
                  fontSize: 9,
                  fontWeight: 600,
                  color: '#5a5e6b',
                  background: '#e9e7e1',
                  padding: '3px 9px',
                  borderRadius: 99,
                  ...fieldStyle(t, 8 + i * 2, 'pop'),
                }}
              >
                {txt}
              </div>
            ))}
          </div>
          {/* 价格行(f=16):f=26 起旧价降级、新价 spring 跳出 */}
          <div style={{ display: 'flex', alignItems: 'baseline', gap: 9, marginBottom: 11, ...fieldStyle(t, 16) }}>
            <span
              style={{
                fontSize: cut ? 15 : 21,
                fontWeight: 800,
                color: cut ? '#9a9da6' : '#17181c',
                textDecoration: cut ? 'line-through' : 'none',
              }}
            >
              $249
            </span>
            <span
              style={{
                fontSize: 21,
                fontWeight: 800,
                color: ACCENT,
                opacity: Math.min(1, nk * 3),
                transform: `scale(${lerp(E.spring(nk, 0.35), 1.15, 1)})`,
              }}
            >
              $189
            </span>
          </div>
          {/* 描述行(f=30 起每 2 帧一行)+ 马克笔高亮(行 f0+4 起由左向右刷过) */}
          <div style={{ fontSize: 10.5, lineHeight: 1.75, color: '#494d58' }}>
            {LINES.map((segs, li) => (
              <div key={li} style={{ whiteSpace: 'nowrap', ...fieldStyle(t, 30 + li * 2) }}>
                {segs.map(([txt, isMark], si) =>
                  !isMark ? (
                    <span key={si}>{txt}</span>
                  ) : (
                    <span key={si} style={{ position: 'relative', display: 'inline-block' }}>
                      <span
                        style={{
                          position: 'absolute',
                          left: -2,
                          right: -2,
                          top: '8%',
                          bottom: '4%',
                          background: ACCENT_SOFT,
                          borderRadius: 2,
                          transform: `scaleX(${seg(t, F(30 + li * 2 + 4), F(30 + li * 2 + 4) + 0.085, E.outCubic)})`,
                          transformOrigin: 'left center',
                        }}
                      />
                      <span style={{ position: 'relative', fontWeight: 700, color: '#26282f' }}>{txt}</span>
                    </span>
                  ),
                )}
              </div>
            ))}
          </div>
          {/* 色卡(f=40 起每 2 帧一个 pop) */}
          <div style={{ display: 'flex', gap: 7, marginTop: 'auto' }}>
            {['#191b20', '#8b8f99', '#3a5b8c'].map((cclr, i) => (
              <div
                key={cclr}
                style={{
                  width: 16,
                  height: 16,
                  borderRadius: 4,
                  background: cclr,
                  outline: '1.5px solid rgba(0,0,0,.12)',
                  outlineOffset: 1.5,
                  ...fieldStyle(t, 40 + i * 2, 'pop'),
                }}
              />
            ))}
          </div>
        </div>
      </div>
    </DesignStage>
  );
};


  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/product-card-progressive-assemble/ProductCardProgressiveAssemble.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