Motion · Tương tác

Chip nâng lên thành viên thuốc người dùng

Chip mục tiêu trong lưới cắt cứng đảo màu đen-trắng trong 3 khung, các chip khác mờ dần thu nhỏ so le theo khoảng cách Manhattan tới nó; mép trái chip đen neo lại và mọc dài thành viên thuốc, gõ từng chữ tên người rồi thắp chấm xanh lá, sau đó kéo một đường nối 1px đến huy hiệu tròn.

1920×108016:930 fps5.5s · 165 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#interaction

Nguồn: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composition Remotion (React/TSX) — có video preview, không có player trong trình duyệt

Khi nào dùng

Luồng tương tác "chọn và mở rộng một mục từ đám ứng viên"; trình diễn tính năng cho sản phẩm cộng tác/sổ liên lạc/người nhận; chuyển cảnh chọn sang chi tiết.

Ý đồ

Cho "chọn" hai chất cảm hoàn toàn khác nhau: khoảnh khắc chọn là cứng (đảo màu 3 khung theo bậc thang, giống :active của UI thật), sau khi chọn là mềm (viên thuốc mọc dài, gõ từng chữ, chấm xanh thắp sáng). Các chip khác mờ dần so le theo khoảng cách, đang nói với người xem "sự chú ý bắt đầu thu hẹp từ đây" — sắp theo khoảng cách quan trọng hơn sắp theo thời gian vì nó cho nhân quả một điểm neo không gian.

Thời lượng & năng lượng

  • Thời lượng: Khoảng 5.0s (150f@30fps).
  • Năng lượng: Trung bình (khoảnh khắc chọn là bùng nổ cứng, sau đó toàn bộ là mọc dài và gõ chữ thong thả).

Công thức cảnh quay (bản gốc tiếng Trung — timing, easing, bảng tham số, lỗi hay gặp)

意图

让"选中"这件事有两段完全不同的质感:选中瞬间是硬的(3 帧台阶化反色, 像真实 UI 的 :active),选中之后是软的(药丸生长、逐字打字、绿点点亮)。 其余 chip 按距离交错淡出,是在告诉观众"注意力从这里开始收拢"—— 距离排序比时间排序重要,因为它给出了空间上的因果。

动效核心

  • 4×3 网格(chip 40×24、间距 10/9),目标位于第 2 列第 2 行,

选中前先 appendChild 提到最上层

  • 反色硬切:a = seg(0.04→0.085, linear) 再台阶化成

a<0.34 ? 0 : a<0.67 ? 0.5 : 1——只有 3 个取值,得到 3 帧硬切感; 背景 #fff→INK、描边 LINE→INK、文字 TXT→#fff 同步跳变

  • 距离交错淡出:其余 chip 按曼哈顿距离 |Δcol|+|Δrow|,

d0 = 0.10 + dist*0.022,各 0.075 走完(outQuad), opacity 1→0 + scale 1→0.9

  • 左缘锚定生长:g = seg(0.26→0.44, outCubic) 驱动 width 40→190px,

left 不变——生长方向单一,读作"展开"而非"移动"

  • 人名逐字:以 g 为自变量(不是 t),字符 0.18 + i*0.062 起、

各 0.05 走完,translateY 2→0;原 2 字母标签在 g*5 内先撤掉

  • 绿点 #35D07F + 0 0 8px 辉光,在 g 的 0.85→1 段 outBack 弹出,

left = w − 15 始终吸在药丸右端

  • 连接线 0.47→0.57 outQuad 从 0 拉到 90px → 徽标 0.56→0.63

scale 0.8→1 淡入 → 字幕 0.6→0.66 出现并逐词加深到 0.92

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 反色时长 | t 0.04→0.085,台阶化成 3 档 | 不要给它缓动:连续插值读作"渐变高亮",台阶才读作按下 | | 距离间隔 | 0.022 / 曼哈顿距离 | 按 index 排序也能错峰,但按距离才有"从选中点扩散"的空间因果 | | 余项退场 | 各 0.075、opacity→0 + scale→0.9 | 只淡出不缩小会显得"被擦掉";缩到 0.9 才像退到后面 | | 药丸生长 | width 40→190px,t 0.26→0.44 outCubic,左缘锚定 | 双向生长(居中扩张)会把它读成弹窗,不是同一个 chip 长大 | | 逐字打字 | 以 g 为自变量,间隔 0.062、各 0.05 | 挂在 g 上而不是 t 上:改生长时长时打字节奏自动跟随 | | 绿点 | 7px + 0 0 8px 辉光,g 的 0.85→1 段 outBack | 必须在生长收尾才亮:早了就成了"已在线",不是"刚接上" | | 连接线 | 0→90px,t 0.47→0.57 outQuad | 线要在药丸完全定型后才起笔,否则读作药丸在被拽 | | 徽标 | 26px,0.56→0.63 outCubic,scale 0.8→1 | 与连接线抵达重叠 0.01,才有"线到即物到"的交棒 |

已知坑

  • 目标 chip 必须在动画前提到 DOM 最上层,否则生长时被右侧 chip 盖住
  • 药丸内的人名容器 left:13px 是按 24px 高的 chip 配的内边距;

改 chip 高度要同改,否则文字贴边

  • 绿点的 left = w − 15 每帧重算——写死坐标后一改 PW1 就飞出药丸
  • 连接线与徽标的坐标全部基于 PX + PW1(药丸终态右缘),

改生长终值必须同改这两处

  • 占位内容:12 个双字母标签 + 人名 "Casey Doe" + 字幕

"Starting with Casey",落地全部替换;人名字符数变化会改变打字总时长 (间隔 0.062 × 字符数必须留在 g 的 1.0 以内)

参考实现

demos/interaction/chip-lift-to-user-pill/ (ChipLiftToUserPill.tsx)

Mã nguồn tham chiếu (Remotion)

  • demos/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.tsx

Component import fixture chung từ demos/_fixtures và helper từ assets/lib của repo; xem SKILL.md trong repo để biết quy trình render.

Mọi thứ trên trang này đều có bản máy đọc. Đưa agent link JSON, hoặc thêm MCP server gimgs rồi bảo nó tìm motion template.

  1. Tải JSON — trong đó có công thức cảnh quay (timing, easing, bảng tham số, lỗi hay gặp) và toàn bộ source TSX.
  2. Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
  3. Đăng ký component thành <Composition> trong project Remotion rồi render bằng `npx remotion render`, hoặc dựng lại công thức bằng engine của bạn.
curl -s https://gimgs.net/motion/chip-lift-to-user-pill.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("chip-lift-to-user-pill")

Prompt

Use the video-shotcraft shot recipe "Chip nâng lên thành viên thuốc người dùng" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/chip-lift-to-user-pill.json
Raw TSX: https://gimgs.net/motion/chip-lift-to-user-pill/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.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

9,713 chars · Apache-2.0Mở template gốc ↗
// ===== demos/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.tsx =====
// chip-lift-to-user-pill — Chip Lift 选中 chip 长成人名药丸(motion-lab 定稿转原生 Remotion)
// 网格里的目标 chip 先 3 帧硬切反色成黑底白字,其余 chip 按到它的距离交错淡出并缩到 0.9;
// 黑 chip 保持左缘不动向右生长成药丸,内部逐字打出人名并点亮绿点,再拉一条 1px 连接线
// 接到圆形徽标,最后走逐词加深字幕。
// 设计坐标 480×270(DesignStage 等比放大),440×240 定尺画布居中排版。
import React from 'react';
import { DesignStage, E, lerp, seg, useT } from '../../_fixtures/Motion';

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

// ---- 本卡共享量(浅灰瑞士极简系配色 / 字体;BG/DIM/h2r 取自 motion-lab/fx/b09.js 同批定义) ----
const SANS = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
const BG = '#F1F1F3'; // 页面浅灰
const INK = '#0B0B0C'; // 纯黑
const TXT = '#111111'; // 正文黑
const DIM = '#C9C9CE'; // 浅灰占位字
const LINE = '#E6E6EA'; // 描边

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const h2r = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
// 颜色插值:mix(p,'#C9C9CE','#111')
const mix = (p: number, a: string, b: string) => {
  const A = h2r(a), B = h2r(b), q = clamp01(p);
  return `rgb(${Math.round(A[0] + (B[0] - A[0]) * q)},${Math.round(A[1] + (B[1] - A[1]) * q)},${Math.round(A[2] + (B[2] - A[2]) * q)})`;
};

// ---- 网格参数(与 effect.js 完全一致) ----
const COLS = 4, ROWS = 3, CW = 40, CH = 24, GX = 10, GY = 9, GX0 = 8, GY0 = 70;
const TC = 1, TR = 1; // 目标 chip 的列/行
const LABELS = ['JD', 'MK', 'CD', 'RL', 'AV', 'TP', 'KN', 'BW', 'CE', 'HR', 'LM', 'DQ'];
// 目标 chip 左上角坐标(左缘锚定,生长时不动)
const TX = GX0 + TC * (CW + GX); // 58
const TY = GY0 + TR * (CH + GY); // 103
// 其余 chip:位置 + 到目标的曼哈顿距离(交错淡出用)
const OTHERS = Array.from({ length: ROWS * COLS }, (_, i) => {
  const r = Math.floor(i / COLS), c = i % COLS;
  return {
    x: GX0 + c * (CW + GX),
    y: GY0 + r * (CH + GY),
    label: LABELS[i],
    dist: Math.abs(c - TC) + Math.abs(r - TR),
    isT: c === TC && r === TR,
  };
}).filter((o) => !o.isT);

const PW0 = CW, PW1 = 190; // 药丸生长的起止宽度
const NAME_CHARS = 'Casey Doe'.split(''); // 药丸内逐字打出的人名

// 结尾字幕(逐词加深语法,只用到 show + inn 两态)
const CAP_WORDS = 'Starting with Casey'.split(' ');
const CAP_ST = 0.78 / CAP_WORDS.length;
const CAP_WIN = CAP_ST * 1.5;

// 徽标尺寸(白底圆 + 四角星)
const BADGE_SIZE = 26;
const BADGE_SVG = Number((BADGE_SIZE * 0.52).toFixed(1)); // 13.5

export const ChipLiftToUserPill: React.FC = () => {
  const t = useT();

  // A 反色硬切(3 帧感):进度台阶化到 0 / 0.5 / 1 → 硬切质感
  const a = seg(t, 0.04, 0.085, E.linear);
  const aq = a < 0.34 ? 0 : a < 0.67 ? 0.5 : 1;

  // C 药丸从左缘生长 + 逐字 + 绿点
  const g = seg(t, 0.26, 0.44, E.outCubic);
  const w = lerp(g, PW0, PW1);
  const dq = seg(g, 0.85, 1, E.outBack);

  // D 连接线 → 徽标 → 字幕
  const cw = seg(t, 0.47, 0.57, E.outQuad);
  const bp = seg(t, 0.56, 0.63, E.outCubic);
  const capShow = seg(t, 0.6, 0.66);
  const capInn = seg(t, 0.6, 0.92);

  return (
    <DesignStage bg={BG}>
      {/* 页面 + 440×240 定尺画布(居中) */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          background: BG,
          overflow: 'hidden',
          fontFamily: SANS,
          WebkitFontSmoothing: 'antialiased',
        }}
      >
        <div style={{ position: 'absolute', left: '50%', top: '50%', width: 440, height: 240, margin: '-120px 0 0 -220px' }}>
          {/* B 其余 chip:按到目标的曼哈顿距离交错淡出 + scale .9 */}
          {OTHERS.map((o, i) => {
            const d0 = 0.10 + o.dist * 0.022;
            const p = seg(t, d0, d0 + 0.075, E.outQuad);
            return (
              <div
                key={i}
                style={{
                  position: 'absolute',
                  left: o.x,
                  top: o.y,
                  width: CW,
                  height: CH,
                  borderRadius: CH / 2,
                  background: '#fff',
                  border: `1px solid ${LINE}`,
                  display: 'flex',
                  alignItems: 'center',
                  boxSizing: 'border-box',
                  overflow: 'hidden',
                  boxShadow: '0 1px 3px rgba(0,0,0,.04)',
                  opacity: 1 - p,
                  transform: `scale(${lerp(p, 1, 0.9)})`,
                }}
              >
                <div
                  style={{
                    position: 'absolute',
                    left: 0,
                    top: 0,
                    width: CW,
                    height: CH,
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    font: `600 10.5px/1 ${SANS}`,
                    letterSpacing: '.6px',
                    color: TXT,
                  }}
                >
                  {o.label}
                </div>
              </div>
            );
          })}

          {/* 目标 chip(最上层):反色硬切 → 左缘锚定向右生长成药丸 */}
          <div
            style={{
              position: 'absolute',
              left: TX,
              top: TY,
              width: w,
              height: CH,
              borderRadius: CH / 2,
              background: mix(aq, '#ffffff', INK),
              border: `1px solid ${mix(aq, LINE, INK)}`,
              display: 'flex',
              alignItems: 'center',
              boxSizing: 'border-box',
              overflow: 'hidden',
              boxShadow: '0 1px 3px rgba(0,0,0,.04)',
            }}
          >
            {/* 原缩写标签:反色后随生长淡出 */}
            <div
              style={{
                position: 'absolute',
                left: 0,
                top: 0,
                width: CW,
                height: CH,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                font: `600 10.5px/1 ${SANS}`,
                letterSpacing: '.6px',
                color: mix(aq, TXT, '#ffffff'),
                opacity: 1 - clamp01(g * 5),
              }}
            >
              CD
            </div>
            {/* 人名逐字打出(stagger 跑在生长进度 g 上) */}
            <div
              style={{
                position: 'absolute',
                left: 13,
                top: 0,
                height: CH,
                display: 'flex',
                alignItems: 'center',
                whiteSpace: 'nowrap',
              }}
            >
              {NAME_CHARS.map((ch, i) => {
                const p = seg(g, 0.18 + i * 0.062, 0.18 + i * 0.062 + 0.05, E.outQuad);
                return (
                  <span
                    key={i}
                    style={{
                      font: `600 11px/1 ${SANS}`,
                      color: '#fff',
                      opacity: p,
                      whiteSpace: 'pre',
                      letterSpacing: '.2px',
                      transform: `translateY(${lerp(p, 2, 0)}px)`,
                      display: 'inline-block',
                    }}
                  >
                    {ch}
                  </span>
                );
              })}
            </div>
            {/* 在线绿点:outBack 弹出,钉在药丸右缘内侧 */}
            <div
              style={{
                position: 'absolute',
                top: (CH - 7) / 2,
                left: w - 15,
                width: 7,
                height: 7,
                borderRadius: '50%',
                background: '#35D07F',
                boxShadow: '0 0 8px rgba(53,208,127,.6)',
                opacity: clamp01(dq * 2),
                transform: `scale(${dq})`,
              }}
            />
          </div>

          {/* 1px 连接线:从药丸右缘拉到徽标 */}
          <div
            style={{
              position: 'absolute',
              left: TX + PW1,
              top: TY + CH / 2,
              height: 1,
              width: `${(cw * 90).toFixed(1)}px`,
              background: TXT,
            }}
          />

          {/* AI 徽标(白底圆 + 四角星) */}
          <div
            style={{
              position: 'absolute',
              width: BADGE_SIZE,
              height: BADGE_SIZE,
              borderRadius: '50%',
              background: '#fff',
              border: `1px solid ${LINE}`,
              boxSizing: 'border-box',
              boxShadow: '0 2px 10px rgba(0,0,0,.07)',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              left: TX + PW1 + 90 - 2,
              top: TY + CH / 2 - 13,
              opacity: bp,
              transform: `scale(${lerp(bp, 0.8, 1)})`,
            }}
          >
            <svg width={BADGE_SVG} height={BADGE_SVG} viewBox="0 0 24 24">
              <path d="M12 0.8 L14.3 9.7 L23.2 12 L14.3 14.3 L12 23.2 L9.7 14.3 L0.8 12 L9.7 9.7 Z" fill={TXT} />
            </svg>
          </div>

          {/* 结尾字幕:逐词加深(浅灰占位 → 黑),整行透明度随 show 淡入 */}
          <div
            style={{
              position: 'absolute',
              display: 'flex',
              alignItems: 'baseline',
              whiteSpace: 'nowrap',
              left: TX + PW1 - 18,
              top: TY + CH + 34,
              opacity: capShow,
            }}
          >
            {CAP_WORDS.map((wd, i) => {
              const q = clamp01((capInn - i * CAP_ST) / CAP_WIN);
              return (
                <span
                  key={i}
                  style={{
                    font: `600 13px/1.25 ${SANS}`,
                    color: mix(q, DIM, TXT),
                    letterSpacing: (-0.03 * (1 - q)).toFixed(4) + 'em',
                    marginRight: i === CAP_WORDS.length - 1 ? 0 : 4.5,
                  }}
                >
                  {wd}
                </span>
              );
            })}
          </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/interaction/chip-lift-to-user-pill/ChipLiftToUserPill.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
—

Cùng danh mục