Motion · Interaction

Type, filter, drill in

Typing a search on a real UI, the grid converges to a single card on its own, then a click drills through into the detail page.

960×54016:930 fps2.4s · 72 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

The "operation narrative" passage of a feature demo; any search/filter/drill-into-detail interaction chain

Intention

Let the viewer "follow along": see exactly what was typed, how the page responds, where the click landed. This is the only shot in the whole film that simulates a real person operating the UI, so the pacing must feel human-handed, never scripted.

Duration & energy

  • Duration: ~2.5s (118–190f)
  • Energy: Medium (a composed beat following a high-energy deal-out passage)

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

意图

让观众"跟着做一遍":看清输入了什么、页面怎么响应、点了哪里。这是全片唯一模拟真人操作的镜头,节奏必须像人手,不能像脚本。

动效核心

  • 相机上移到搜索框,逐字符打字,光标常亮→闪烁
  • 打完留一口气,然后非目标卡按阅读序错峰淡出下沉,网格收敛
  • 目标卡滑到首行槽位,途中浮起 + 阴影变宽
  • 双圈强调色(模板片为琥珀)ripple 点击确认,相机推进穿透进详情页交棒转场

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 打字速度 | 3f/字符(TYPE_START=128,"nano-lab" 8 字符 ≈149 收尾) | 初版嫌快返工后的定稿值("unhurried");交互演示按真人操作速度走(R3) | | 呼吸位 | 打完到过滤 FILTER_START=160,留 ~11f(0.37s) | 打完立刻过滤读作机器自动,观众跟不上因果 | | 光标 | 打字时常亮,之后 8f 周期闪烁;点击 176f,光标 185f 消失 | 打字中闪烁会读作卡顿;常亮→闪烁的切换本身就是"打完了"的信号 | | 过滤退场 | 25 张非目标卡按阅读序 0.4f 间隔错峰,各 5f 淡出+下沉 8px | 同时消失读作页面崩溃;错峰哪怕只差 0.4f 也够 | | 目标卡滑位 | 10f 滑到首行槽位,bezier(0.35,0,0.2,1),途中 sin 浮起 z 18px、scale +0.02、阴影随浮起变宽 | 终点必须是布局真实槽位(Q9),滑到画面中央悬停就假了 | | 点击确认 | 两圈同心强调色 ripple(起点差 3f、各 10f、半径 14→54/78)+ 3px 描边 + 40px 辉光,随后相机 16f 推进 zoom 2.2 | 单圈 ripple 太轻看不见;推进交棒给 flash-cut 盖住换页硬切(组件见 assets/lib/FlashCut.tsx,用法:from = 切点 − 5,跨骑硬切两侧各 5f) | | 搜索框 | 页面底色补丁盖掉纹理里的 placeholder(保留放大镜图标),文字层叠上去打 | 直接在截图纹理上叠字会和烤入的 placeholder 重影 |

声音

打字段钉 keyboard.mp3 并按 Sequence 截 24f 与动作严格等长(S4);点击钉 click-camera(全片最响 vol 0.6,S2 响度分层);网格飞走钉 whoosh-fast。

已知坑

  • 初版打字+过滤"太快了"被返工(R3)——交互镜头的第一版几乎总是偏快,起稿就按 3f/字符
  • 过滤后目标卡若不归位到真实槽位、悬浮在网格上方,读作假(Q9,曾因此近乎整文件重写)

参考实现

demos/interaction/type-and-filter/TypeAndFilter.tsx(原 template/src/aifl/live/SceneFlyIn.tsx 118–190 段)

Reference implementation (Remotion)

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

Prompt

Use the video-shotcraft shot recipe "Type, filter, drill in" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/type-and-filter.json
Raw TSX: https://gimgs.net/motion/type-and-filter/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/interaction/type-and-filter/TypeAndFilter.tsx; imports demos/_fixtures and assets/lib)
Spec: 960x540, 30 fps, 2.4s

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,412 chars · Apache-2.0Open raw template ↗
// ===== demos/interaction/type-and-filter/TypeAndFilter.tsx =====
// type-and-filter —— 真实 UI 上打字搜索、网格自己收敛成一张卡、点击穿透进详情页
// 功能演示的"操作叙事"段:让观众"跟着做一遍"。节奏必须像人手,不能像脚本。
// 参考实现从 template SceneFlyIn 118–190 段剥离(self-contained):
// 相机移到搜索框,逐字符打字(3f/字符,光标常亮→闪烁),打完留一口气(11f),
// 25 张非目标卡按阅读序错峰淡出+下沉,目标卡滑到首行槽位(途中浮起+阴影变宽),
// 双圈强调色 ripple 点击确认 + 3px 描边 + 辉光,相机 16f 推进 zoom 2.2 交棒。
// 网格卡用真实纹理裁片(复用 projects-empty.png 里已有的卡),nano-lab 目标卡
// 过滤后滑到首行槽位。缺的 float-* 小块用纯色占位,不影响动效读感。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';

export const TYPE_AND_FILTER_DURATION = 73; // 118–190f 落在 shot 内 offset 0

const PAGE_H = layout.projects.pageH;
const cards = layout.projects.cards;
const PAPER = '#f9f6f1';
const FIELD = '#fefcf9';
const AMBER = 'oklch(58% 0.13 65)';
const QUERY = 'nano-lab';
const TYPE_START = 10; // 118 - 118 = 0 偏移基准:scene-local 从 118 起
const FILTER_START = 42; // 160 - 118

// search box (page-space CSS px)
const SEARCH = { x: 408, y: 130, w: 1016, h: 44 };

// nano-lab target card (the one that filters to row one)
const NANO_TITLE = 'nano-lab';
const nanoIdx = cards.findIndex((c) => c.title.includes(NANO_TITLE));
const nano = cards[nanoIdx];
const NANO_TO = { x: 408, y: 247 }; // first-row slot
const CLICK_C = { x: 586, y: 391 }; // click point on the settled result card

// filter departure rank for the non-target cards (reading order)
const leaveRank = new Map<number, number>();
cards.forEach((_, i) => {
  if (i !== nanoIdx) leaveRank.set(i, leaveRank.size);
});

// camera: hold through typing/breath/filter/slide, then push into the clicked card
const CAM_KEYS: CamKey2D[] = [
  { frame: 0, cx: 960, cy: 380, zoom: 0.9 },
  { frame: 56, cx: 960, cy: 380, zoom: 0.9 },
  { frame: 72, cx: CLICK_C.x, cy: CLICK_C.y, zoom: 2.2 },
];

export const TypeAndFilter: React.FC = () => {
  const frame = useCurrentFrame();

  // typed chars: one every 3 frames (unhurried)
  const typedCount =
    frame < TYPE_START ? 0 : Math.min(QUERY.length, Math.floor((frame - TYPE_START) / 3) + 1);
  // caret: solid while typing, blinks on an 8f cycle after, until the click
  const caretOn =
    frame >= TYPE_START - 2 &&
    frame <= 67 &&
    (frame <= TYPE_START + 24 || Math.floor((frame - (TYPE_START + 24)) / 8) % 2 === 0);

  return (
    <AbsoluteFill style={{ backgroundColor: PAPER }}>
      <PageCam2D src="textures/live/projects-empty.png" pageH={PAGE_H} keys={CAM_KEYS}>
        {/* ---- 25 non-target cards: staggered fade+sink on filter ---- */}
        {cards.map((c, i) => {
          if (i === nanoIdx) return null;
          const outCue = FILTER_START + leaveRank.get(i)! * 0.4;
          if (frame >= outCue + 5) return null;
          const outT = interpolate(frame, [outCue, outCue + 5], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad),
          });
          return (
            <div
              key={`${c.file}-${i}`}
              style={{
                position: 'absolute', left: c.x, top: c.y, width: c.w, height: c.h,
                transform: `translate3d(0px, ${8 * outT}px, 0px)`,
                opacity: 1 - outT, borderRadius: 16, overflow: 'hidden',
                boxShadow: '0 2px 6px rgba(60,45,30,.08)',
              }}
            >
              <Img src={staticFile(`textures/live/${c.file}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
            </div>
          );
        })}

        {/* ---- nano-lab: slides up to the first-row slot on filter, floating ---- */}
        {(() => {
          const slideT = interpolate(frame, [FILTER_START, FILTER_START + 10], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.35, 0, 0.2, 1),
          });
          const slideDy = (NANO_TO.y - nano.y) * slideT;
          const slideDx = (NANO_TO.x - nano.x) * slideT;
          const float = Math.sin(slideT * Math.PI);
          const slideScale = 1 + 0.02 * float;
          const slideZ = 18 * float;
          return (
            <div
              style={{
                position: 'absolute', left: nano.x, top: nano.y, width: nano.w, height: nano.h,
                transform: `translate3d(${slideDx}px, ${slideDy}px, ${slideZ}px) scale(${slideScale})`,
                transformOrigin: 'center center',
                boxShadow: `0 ${2 + 14 * float}px ${6 + 26 * float}px rgba(60,45,30,${0.08 + 0.1 * float})`,
                borderRadius: 16, overflow: 'hidden', zIndex: 2,
              }}
            >
              <Img src={staticFile(`textures/live/${nano.file}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
            </div>
          );
        })()}

        {/* ---- search box: paper patch over the placeholder (keeps magnifier), typed text + caret ---- */}
        {frame >= 0 ? (
          <div
            style={{
              position: 'absolute', left: 440, top: SEARCH.y + 4,
              width: SEARCH.x + SEARCH.w - 448, height: SEARCH.h - 8,
              background: FIELD, opacity: 1, pointerEvents: 'none',
            }}
          />
        ) : null}
        <div
          style={{
            position: 'absolute', left: 448, top: SEARCH.y, height: SEARCH.h,
            display: 'flex', alignItems: 'center',
            fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif',
            fontSize: 15, letterSpacing: 0.2, color: 'oklch(25% 0.006 82)', pointerEvents: 'none',
          }}
        >
          <span>{QUERY.slice(0, typedCount)}</span>
          {caretOn ? (
            <span style={{ display: 'inline-block', width: 2, height: 20, marginLeft: 2, background: AMBER }} />
          ) : null}
        </div>

        {/* ---- click ripple: two concentric amber rings ---- */}
        {[0, 1].map((r) => {
          const start = 58 + r * 3; // 176 - 118
          if (frame < start || frame > start + 10) return null;
          const t = interpolate(frame, [start, start + 10], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic),
          });
          const rad = interpolate(t, [0, 1], [14, r === 0 ? 54 : 78]);
          return (
            <div
              key={`ripple-${r}`}
              style={{
                position: 'absolute', left: CLICK_C.x - rad, top: CLICK_C.y - rad,
                width: rad * 2, height: rad * 2, borderRadius: '50%',
                border: `2px solid ${AMBER}`, opacity: 1 - t, pointerEvents: 'none', zIndex: 5,
              }}
            />
          );
        })}

        {/* ---- selected-card amber outline, lit from the click ---- */}
        {frame >= 60 ? (
          <div
            style={{
              position: 'absolute', left: NANO_TO.x - 6, top: NANO_TO.y - 6,
              width: nano.w + 12, height: nano.h + 12, borderRadius: 16,
              border: `3px solid ${AMBER}`, boxShadow: '0 0 40px rgba(180,120,50,0.5)',
              opacity: interpolate(frame, [60, 63], [0.5, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
              pointerEvents: 'none', zIndex: 4,
            }}
          />
        ) : null}
      </PageCam2D>
    </AbsoluteFill>
  );
};


  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/type-and-filter/TypeAndFilter.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