Motion · Interaction

Chip Grid Blackout Select

Five option chips fade in one by one in a centered 3+2 layout; the selected one flashes a gray press frame, then within a few frames turns solid black with white text and a subtle 1→1.04→1 bounce, while other chips fade to 18% without moving; remaining chips then clear, and the black chip rises and narrows as a calculation row appears below.

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

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

When to use

Interactive demos of single-select/plan/tier picking; the "what happens after you pick it" causal shot; pricing/parameter-checkout flows.

Intention

One gray flash frame plus a three-frame reverse-to-black split apart real UI's :active → :selected two-stage state into separate beats. Viewers see a "finger press" and then a "system confirms," rather than one gradient. Other chips drop to 18% opacity but their positions never move — if they reflowed, viewers would think the page reloaded, and the semantics of "single select" would be lost.

Duration & energy

  • Duration: About 5.0s (150f@30fps).
  • Energy: Medium-low (the only burst is that one gray flash frame; the rest is settling).

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

意图

一帧灰闪 + 三帧反黑,是把真实 UI 的 :active → :selected 两级状态 拆开演。观众看到的是"手指按下"再"系统确认",而不是一次渐变。其余 chip 降到 18% 而位置绝不移动:一旦重排,观众会以为页面刷新了, "单选"的语义就丢了。

动效核心

  • 5 个 chip 分 3+2 两行居中(top 96/136,gap 10,高 30、圆角 15),

按 d = 0.05 + i*0.028 逐个 outQuad 淡入(各 0.04)

  • 1 帧按压灰闪:seg(FS, FS+0.006) * (1 − seg(FS+0.006, FS+0.014)),

rgba(120,120,120,.5) 覆盖层(FS=0.44,0.006×5000ms ≈ 1 帧)

  • 反黑:seg(FS+0.008 → FS+0.04, linear)(≈5 帧)驱动

背景 #fff→INK、描边 LINE→INK、文字 TXT→#fff

  • 按压回弹:sc = 1 + sin(pr·π)*0.04,pr 走 FS+0.008→FS+0.075

——正弦保证起止都精确回到 1,不需要额外关键帧

  • 余项降级:fade = seg(FS+0.008 → FS+0.075, outQuad) 把 opacity

压到 lerp(fade, 1, 0.18) = 18%,transform 恒为 none

  • 收束:lift = seg(FS+0.30 → FS+0.42, inOutCubic)(选中后≈1.5s)

余项 opacity 归零,黑 chip translate(cx·lift, −46·lift) + scale→0.82; cx = 220 − (offsetLeft + width/2) 只测一次,让它上移时同时回到中线

  • 算式行 FS+0.36→FS+0.42 浮现,随后逐词加深到 t≈0.98

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 入场 | 间隔 0.028、各 0.04、outQuad | 5 个 chip 在前 0.2 铺完,把时间留给选中段 | | 选中时刻 | FS = 0.44(约全片中点) | 前半读题、后半看结果;FS 早于 0.3 观众还没读完选项 | | 灰闪 | 0.006 亮 + 0.008 落,rgba(120,120,120,.5) | 就一帧:给 2 帧以上就读作 hover 高亮,不是按下 | | 反黑 | FS+0.008→FS+0.04 linear(≈5f) | linear 是故意的:加 ease 会让"变黑"这件事有过程感,太软 | | 按压回弹 | 1 + sin(pr·π)·0.04,跨 0.067 | 0.04 是极轻;到 0.1 就成了弹跳按钮,抢戏 | | 余项降级 | 降到 18%,位置 transform:none | 位置锁死是命门:降到 18% 仍在原位,才读作"没被选中"而非"消失了" | | 收束时刻 | FS+0.30(选中后≈1.5s)起,0.12 inOutCubic | 1.5s 是让观众看清选中态的最短驻留;缩到 0.5s 就赶 | | 上移收窄 | translateY −46px、scale→0.82,横向补 cx 回中线 | 不补 cx 的话上移后会偏在左边,读作"被拖走" |

已知坑

  • cx 用 measured 标志只测一次(首帧 offsetWidth 可能为 0 所以要

判空)——每帧重测会在 chip 自身缩放后越算越偏

  • 其余 chip 的 transform 必须显式写 'none',靠 flex 自动重排会让

它们在余项归零时横向滑动

  • 选中项要在同一个 flex 行里做 translate,不能改 position,

否则触发行内重排、其余 chip 位置跟着变

  • 占位内容:5 条 "Option … plan" 选项名 + 算式行

"18% off · 42.00 → 34.44",落地全部替换;选项名过长会撑破单行 (第三条已是长名压力测试)

  • 与 chip-lift-to-user-pill 分工:那张选中后横向生长成新对象,

本卡选中后上移收窄让位给结果;同一片子里两者不要连用

参考实现

demos/interaction/chip-grid-single-select-blackout/ (ChipGridSingleSelectBlackout.tsx)

Reference implementation (Remotion)

  • demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.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/chip-grid-single-select-blackout.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.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-grid-single-select-blackout")

Prompt

Use the video-shotcraft shot recipe "Chip Grid Blackout Select" (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-grid-single-select-blackout.json
Raw TSX: https://gimgs.net/motion/chip-grid-single-select-blackout/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.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

7,563 chars · Apache-2.0Open raw template ↗
// ===== demos/interaction/chip-grid-single-select-blackout/ChipGridSingleSelectBlackout.tsx =====
// chip-grid-single-select-blackout — Single Select 1 帧灰闪单选反黑(motion-lab 定稿转原生 Remotion)
// 五个选项 chip 以 3+2 居中排布逐个淡入;选中帧先插一帧灰色按压块,紧接 3 帧内底色变纯黑、
// 文字变白并做 1→1.04→1 的极轻回弹,同窗口其余 chip 淡到 18% 但位置锁死;1s 后余项归零,
// 黑 chip 上移缩小,下方浮现算式行。
// 设计坐标 480×270(DesignStage 等比放大),440×240 定尺画布居中排版。
import React, { useLayoutEffect, useRef, useState } from 'react';
import { DesignStage, E, lerp, seg, useT } from '../../_fixtures/Motion';

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

// ---- 本卡共享量(浅灰瑞士极简系配色 / 字体;BG/DIM/clamp01/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 NAMES = ['Option one plan', 'Option two plan', 'Option three long name', 'Option four', 'Option five variant'];
const TI = 0;   // 选中项下标
const FS = 0.44; // 灰闪起点

// 算式行(逐词加深语法)。注意原文含双空格:split(' ') 产生的空串"词"
// 也各占一个 4.5px margin 的空 span,是间距配方的一部分,必须保留。
const CAP_TEXT = '18% off  ·  42.00  →  34.44';
const CAP_WORDS = CAP_TEXT.split(' ');
const CAP_N = CAP_WORDS.length;
const CAP_ST = 0.78 / CAP_N;
const CAP_WIN = CAP_ST * 1.5;

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

  // 选中 chip 上移时同步回到水平中线:cx = 220 - 选中 chip 在 440 画布内的中心 x。
  // chip 宽度由文本 + padding 决定(flex 布局),挂载后实测一次;149 为兜底估算
  // ("Option one plan" 600 12px ≈ 90px 文本 + 30 padding + 2 border,行首起点 ≈ 11)。
  const targetRef = useRef<HTMLDivElement>(null);
  const [cx, setCx] = useState(149);
  useLayoutEffect(() => {
    const el = targetRef.current;
    if (el && el.offsetWidth) setCx(220 - (el.offsetLeft + el.offsetWidth / 2));
  }, []);

  const capShow = seg(t, FS + 0.36, FS + 0.42);
  const capInn = seg(t, FS + 0.37, 0.98);

  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' }}>
          {/* 标题 */}
          <div
            style={{
              position: 'absolute',
              left: 0,
              right: 0,
              top: 26,
              textAlign: 'center',
              font: `600 11px/1 ${SANS}`,
              letterSpacing: 2.4,
              color: '#9A9AA2',
              opacity: seg(t, 0.02, 0.1, E.outQuad),
            }}
          >
            OPTION GROUP
          </div>

          {/* 两行 chip:3+2 居中排布(flex 布局,位置全程锁死不重排) */}
          {[0, 1].map((row) => (
            <div
              key={row}
              style={{
                position: 'absolute',
                left: 0,
                right: 0,
                top: row === 0 ? 96 : 136,
                display: 'flex',
                justifyContent: 'center',
                gap: 10,
              }}
            >
              {NAMES.map((n, i) => {
                if ((i < 3 ? 0 : 1) !== row) return null;
                const d = 0.05 + i * 0.028;
                const inP = seg(t, d, d + 0.04, E.outQuad);
                let bg = '#fff';
                let borderColor = LINE;
                let labelColor: string = TXT;
                let flashO = '0';
                let opacity: string | number;
                let transform: string;
                if (i === TI) {
                  // 灰闪 1 帧
                  flashO = (seg(t, FS, FS + 0.006) * (1 - seg(t, FS + 0.006, FS + 0.014))).toFixed(3);
                  const bk = seg(t, FS + 0.008, FS + 0.04, E.linear);
                  bg = mix(bk, '#ffffff', INK);
                  borderColor = mix(bk, LINE, INK);
                  labelColor = mix(bk, TXT, '#ffffff');
                  // 按压回弹 1→1.04→1
                  const pr = seg(t, FS + 0.008, FS + 0.075, E.linear);
                  const sc = 1 + Math.sin(pr * Math.PI) * 0.04 * (pr > 0 ? 1 : 0);
                  const lift = seg(t, FS + 0.30, FS + 0.42, E.inOutCubic);
                  opacity = inP;
                  transform =
                    `translate(${(cx * lift).toFixed(2)}px,${(-46 * lift).toFixed(2)}px) ` +
                    `scale(${(sc * lerp(lift, 1, 0.82)).toFixed(4)})`;
                } else {
                  const fade = seg(t, FS + 0.008, FS + 0.075, E.outQuad);
                  const gone = seg(t, FS + 0.30, FS + 0.35, E.outQuad);
                  opacity = (inP * lerp(fade, 1, 0.18) * (1 - gone)).toFixed(3);
                  transform = 'none';
                }
                return (
                  <div
                    key={i}
                    ref={i === TI ? targetRef : undefined}
                    style={{
                      position: 'relative',
                      height: 30,
                      borderRadius: 15,
                      background: bg,
                      border: `1px solid ${borderColor}`,
                      boxSizing: 'border-box',
                      display: 'flex',
                      alignItems: 'center',
                      padding: '0 15px',
                      boxShadow: '0 1px 4px rgba(0,0,0,.04)',
                      overflow: 'hidden',
                      opacity,
                      transform,
                    }}
                  >
                    <div style={{ font: `600 12px/1 ${SANS}`, color: labelColor, letterSpacing: '-.01em', whiteSpace: 'nowrap' }}>
                      {n}
                    </div>
                    {/* 按压灰闪块(盖在 label 上) */}
                    <div style={{ position: 'absolute', inset: 0, background: 'rgba(120,120,120,.5)', opacity: flashO }} />
                  </div>
                );
              })}
            </div>
          ))}

          {/* 算式行:逐词加深(浅灰占位 → 黑),整行透明度随 show 淡入 */}
          <div
            style={{
              position: 'absolute',
              left: '50%',
              top: 150,
              display: 'flex',
              alignItems: 'baseline',
              whiteSpace: 'nowrap',
              transform: 'translateX(-50%)',
              opacity: capShow,
            }}
          >
            {CAP_WORDS.map((w, i) => {
              const q = clamp01((capInn - i * CAP_ST) / CAP_WIN);
              return (
                <span
                  key={i}
                  style={{
                    font: `600 14px/1.25 ${SANS}`,
                    color: mix(q, DIM, TXT),
                    letterSpacing: (-0.03 * (1 - q)).toFixed(4) + 'em',
                    marginRight: i === CAP_N - 1 ? 0 : 4.5,
                  }}
                >
                  {w}
                </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-grid-single-select-blackout/ChipGridSingleSelectBlackout.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