Motion · UI entrance

List stack press

List cards fly up from the bottom of the frame one by one to stack up, each landing squashing and rebounding the whole pile while a counter ticks up in sync.

960×54016:930 fps3.5s · 105 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

For feed/radar/inbox shots that say "something new every day"; emphasizes a continuously accumulating asset list.

Intention

"Stacking has weight": each new card landing presses the settled pile down and lets it rebound — the audience reads "this was genuinely accumulated" from the physical feedback. The counter ticking in sync locks in the sense of quantity.

Duration & energy

  • Duration: About 3s (18–88f)
  • Energy: Medium

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

意图

"堆叠有重量":每张新卡落上来,已落定的整摞被压下再弹回——观众从物理反馈里读出"这是实打实攒下来的东西"。计数器同步跳格把数量感钉死。

动效核心

  • 预备拍(anticipation,借鉴迪士尼法则 2):首张升入前给一拍段落级预备——计数器先于首卡 4–6f 亮起归零/微缩 scale(0.96)→1,把视线引到堆叠区,再让首卡入画。只做段落级一次,不逐卡加
  • 卡从画面下方升入,交替微倾斜收平,等距节拍逐张摞起
  • 每张到场压下整摞 6px、8f 弹回(stackPress 脉冲——即法则 5 的 follow-through:新卡的动能传给整摞再缓冲耗散,这一笔就是"有重量"的来源)
  • 落地后按拖拽层级依次收尾(法则 5):卡体先停 → 整摞弹回 → 高亮条再长出(滞后 2–4f)——三者同帧发生读作贴图切换
  • 收尾一道 glaze 扫光掠过整摞
  • 屏幕空间 DigitRoll 计数器落一张滚一格;相机跟随堆叠向下

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 节拍 | CUES=[18,30,42,54,66](12f 等距),每张飞 22f,bezier(0.45,0.05,0.25,1.12) 末端过冲 | 少量卡(5 张)可以等距;大批量入场才需要递加速(对照 deck-deal 的 R2) | | 入场 | 从下方 600px 升入,交替 ±2° tilt 收平,scale 1.06→1 | 交替倾斜打破对称;同向倾斜读作复制粘贴 | | 堆叠压感 | 后一张到场时整摞压下 6px、8f 弹回(脉冲 [cue,cue+4,cue+8]→[0,6,0]) | 这是"有重量"的关键一笔,砍掉后堆叠变成简单排版 | | 阴影 | 空中 0 32px 64px、落定 0 2px 8px | 阴影随高度收敛,和 deck-deal/spotlight 同一套语言 | | 联动高亮 | 落地后滞后 2–4f 于 72% 高度处长出 40% 宽强调色(模板片为琥珀)底色(7f 长 + 5f 淡);最后一张离切点仅 ~12f 需压缩节拍 | 高亮是次级动作(法则 5/8):在主动作(落地压弹)之后跟进,不与之同帧抢戏;末尾元素的节拍要按剩余帧预算压缩,不能照抄前面的 | | glaze 扫光 | 420px 宽、rotate 14°、mixBlendMode overlay,帧 82→96 从 −700 扫到 2600 | 扫光全镜头只此一次、扫整摞不逐卡(Q4) | | 计数器(digit-roll 手法) | 连续里程计位置 pos:每张卡落地让 pos 沿数字带 +1 格,单格 8f 滚定,bezier(0.25,0.8,0.25,1);滚动量=pos·lineH 沿双份 0-9 带连续下滚 | 计数与落地不同步立刻穿帮;单格滚动必须短于落卡间距(12f)且尾格在镜头收尾前滚定——以 count 为 key 重挂重滚的写法每次都要从 0 滚 22f,12f 节拍下前一次永远滚不完、尾帧停在 4(PR #40 判例);数字带双份拼接+overflow hidden+tabular-nums(不开 tabular 会横向抖) | | 相机 | zoom 1.35→0.9 五键跟随堆叠向下 | 正视机位(见已知坑),相机只做纵向跟随 |

声音

每张落地不逐张配长音;计数落定钉 click-camera(模板片钉在 f648)、段落切入钉 transition-soft(f623)。若逐张配 pop 参照 S2 三招防机枪。

已知坑

  • 堆叠/列表信息镜头必须正视(Q6)——全片倾斜化时本镜头曾被单独回滚回正视;风格化机位逐镜头验证,不全局套用
  • 逐卡 glint 高亮扫过被否两次(Q4),高亮用静态强调色底色条即可,动感交给压弹
  • 与 history-list-stack(document-typewriter-reveal 卡尾段)是同一动作词汇——全片同一手法只当一次主角(P4),两处并用时一处必须降为配角
  • anticipation/拖拽层级参数借鉴自迪士尼 12 法则(pixel2motion skill 内置提炼版),非用户判例——是默认建议不是令;与判例冲突时判例优先
  • 预备动作幅度必须过肉眼阈值(deck-deal 判例 2026-07-09:小幅度用户完全无感,放大 12 倍才通过)——渲染后自查"不逐帧能否看出蓄力"

参考实现

demos/ui-entrance/list-stack-press/ListStackPress.tsx(原 template/src/aifl/live/ScenePapers.tsx)

Reference implementation (Remotion)

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

Prompt

Use the video-shotcraft shot recipe "List stack press" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/list-stack-press.json
Raw TSX: https://gimgs.net/motion/list-stack-press/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/list-stack-press/ListStackPress.tsx; imports demos/_fixtures and assets/lib)
Spec: 960x540, 30 fps, 3.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,971 chars · Apache-2.0Open raw template ↗
// ===== demos/ui-entrance/list-stack-press/ListStackPress.tsx =====
// list-stack-press —— 列表卡从底部逐张飞上摞起,每张落地压弹整摞、计数器同步跳格
// "堆叠有重量":每张新卡落上来,已落定的整摞被压下再弹回——物理反馈里读出
// "这是实打实攒下来的东西"。参考实现从 template ScenePapers 剥离:
// 预备拍(计数器先于首卡 4–6f 亮起微缩,把视线引到堆叠区);5 卡 12f 等距节拍
// 从底部 600px 升入(交替 ±2° 倾斜收平 + scale 1.06→1);后一张到场时整摞压下
// 6px、8f 弹回(stackPress 脉冲——"有重量"的关键一笔);落地后高亮条滞后 2–4f
// 长出;收尾一道 glaze 扫光掠过整摞;屏幕空间 DigitRoll 计数器落一张滚一格。
// 正视机位(堆叠/列表镜头必须正视,Q6),相机跟随堆叠向下。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';

export const LIST_STACK_PRESS_DURATION = 88; // 18–88f 落在 shot 内 offset 0

const cards = layout.papers.cards;
const PAGE_H = layout.papers.pageH;
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
const AMBER = 'oklch(52% 0.115 65)';
const FILES = ['paper1.png', 'paper2.png', 'paper3.png', 'paper4.png', 'paper5.png'];

const CUES = [6, 18, 30, 42, 54]; // 首卡前留 6f 给计数器预备拍(anticipation)
const DUR = 22;
const FLY_EASE = Easing.bezier(0.45, 0.05, 0.25, 1.12);
const TILTS = [2, -2, 2, -2, 2];

// 预备拍:计数器先于首卡亮起 + 微缩 (0.96→1),把视线引到堆叠区(段落级一次)
const ANTICIPATE_START = 0;
const ANTICIPATE_END = 6;

const CAM_KEYS: CamKey2D[] = [
  { frame: 0, cx: 960, cy: 270, zoom: 1.35 },
  { frame: 12, cx: 960, cy: 330, zoom: 1.15 },
  { frame: 18, cx: 960, cy: 520, zoom: 1.02 },
  { frame: 56, cx: 960, cy: 820, zoom: 0.95 },
  { frame: 82, cx: 960, cy: 860, zoom: 0.9 },
];

// odometer digit column —— 连续里程计,不重挂:`pos` 是数字带上的连续位置
// (0→5),每张卡落地让 pos 多滚进一格。单格滚动必须短于 12f 落卡间距、
// 且最后一格在镜头结束前落定——key 重挂从 0 重滚 22f 的写法两条都踩
// (前一格永远滚不完、尾帧停在 4)。
const DIGITS = '0123456789';
const ROLL_DUR = 8; // 单格滚动帧数,< 12f 落卡间距
const DigitRoll: React.FC<{ pos: number; lineH: number; color: string }> = ({ pos, lineH, color }) => (
  <span style={{ display: 'inline-flex', overflow: 'hidden', height: lineH, verticalAlign: 'bottom' }}>
    <span style={{ display: 'inline-block', height: lineH }}>
      <span style={{ display: 'block', transform: `translateY(${-pos * lineH}px)` }}>
        {(DIGITS + DIGITS).split('').map((d, j) => (
          <span key={j} style={{ display: 'block', fontSize: 96, lineHeight: `${lineH}px`, color, fontVariantNumeric: 'tabular-nums' }}>
            {d}
          </span>
        ))}
      </span>
    </span>
  </span>
);

export const ListStackPress: React.FC = () => {
  const frame = useCurrentFrame();
  // 计数器连续位置:每张卡落地(cue+DUR)时向前滚一格,8f 滚定。
  // 最后一张 f76 落地、f84 滚定,早于 f88 镜头结束。
  let rollPos = 0;
  for (const c of CUES) {
    rollPos += interpolate(frame, [c + DUR, c + DUR + ROLL_DUR], [0, 1], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.25, 0.8, 0.25, 1),
    });
  }

  // 预备拍:计数器 0→6f 从 scale 0.96 微缩回 1 并亮起,视线先引到堆叠区
  const antT = interpolate(frame, [ANTICIPATE_START, ANTICIPATE_END], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),
  });
  const counterScale = 0.96 + 0.04 * antT;
  const counterOpacity = 0.3 + 0.7 * antT;

  // when a *later* card enters, the settled stack gets pressed down 6px then
  // springs back over ~8 frames
  const stackPress = (settledIndex: number) => {
    let press = 0;
    for (let j = settledIndex + 1; j < CUES.length; j++) {
      const cue = CUES[j];
      const p = interpolate(frame, [cue, cue + 4, cue + 8], [0, 6, 0], {
        extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      });
      press = Math.max(press, p);
    }
    return press;
  };

  // glaze sweep once everything has landed (~78)
  const glazeX = interpolate(frame, [64, 78], [-700, 2600], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.45, 0, 0.35, 1),
  });
  const glazeVis = interpolate(frame, [63, 68, 74, 77], [0, 1, 1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ backgroundColor: '#faf7f2' }}>
      <PageCam2D src="textures/live/papers-full.png" pageH={PAGE_H} keys={CAM_KEYS}>
        {/* cover the printed cards so blank slots stack in */}
        {cards.map((c, i) => (
          <div
            key={`slot-${i}`}
            style={{
              position: 'absolute', left: c.x - 12, top: c.y - 10,
              width: c.w + 24, height: c.h + 20, background: '#faf7f2',
              opacity: frame >= CUES[i] + DUR - 2 ? 0 : 1,
            }}
          />
        ))}

        {cards.map((c, i) => {
          const cue = CUES[i];
          const t = interpolate(frame, [cue, cue + DUR], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: FLY_EASE,
          });
          if (t <= 0) return null;
          const settled = t >= 0.999;
          const dy = 600 * (1 - t) + (settled ? stackPress(i) : 0);
          const rot = TILTS[i] * (1 - t);
          const scale = 1.06 - 0.06 * t;
          const shadow = settled
            ? '0 2px 8px rgba(60,45,30,.10)'
            : `0 32px 64px rgba(60,45,30,${0.22 * (1 - t) + 0.06})`;

          // amber highlight over the linked-project name, after landing
          const hlStart = cue + DUR;
          const hlGrow = interpolate(frame, [hlStart, hlStart + 7], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
          });
          const hlFade = interpolate(frame, [hlStart + 7, hlStart + 12], [1, 0], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
          });

          return (
            <div
              key={FILES[i]}
              style={{
                position: 'absolute', left: c.x, top: c.y, width: c.w, height: c.h,
                transform: `translateY(${dy}px) rotate(${rot}deg) scale(${scale})`,
                transformOrigin: 'center center', boxShadow: shadow, borderRadius: 12,
              }}
            >
              <Img src={staticFile(`textures/live/${FILES[i]}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
              {hlGrow > 0 && hlFade > 0 ? (
                <div
                  style={{
                    position: 'absolute', left: 20, top: c.h * 0.72,
                    width: `${hlGrow * 40}%`, height: 30,
                    background: 'oklch(97% 0.028 85)', opacity: 0.6 * hlFade,
                    borderRight: `2px solid ${AMBER}`, pointerEvents: 'none',
                  }}
                />
              ) : null}
            </div>
          );
        })}

        {/* page-space glaze sweep at the end */}
        <div
          style={{
            position: 'absolute', top: 40, height: PAGE_H - 80, left: glazeX, width: 420,
            transform: 'rotate(14deg)', opacity: glazeVis * 0.5, mixBlendMode: 'overlay',
            background: 'linear-gradient(90deg, transparent, rgba(255,240,214,0.9) 45%, rgba(255,240,214,0.9) 55%, transparent)',
            pointerEvents: 'none',
          }}
        />
      </PageCam2D>

      {/* screen-space counter, top-right — lands one digit per card */}
      <div
        style={{
          position: 'absolute', top: 70, right: 96, textAlign: 'right',
          pointerEvents: 'none', opacity: counterOpacity,
          transform: `scale(${counterScale})`, transformOrigin: 'top right',
        }}
      >
        <div style={{ fontFamily: MONO, fontSize: 24, letterSpacing: '0.16em', color: 'oklch(50% 0.006 82)', textTransform: 'uppercase' }}>
          Paper Radar
        </div>
        <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 6 }}>
          <DigitRoll pos={rollPos} lineH={96 * 1.15} color={AMBER} />
        </div>
        <div style={{ fontFamily: MONO, fontSize: 20, letterSpacing: '0.12em', color: 'oklch(50% 0.006 82)', marginTop: 4, textTransform: 'uppercase' }}>
          Of 31 Fetched Today
        </div>
      </div>
    </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/ui-entrance/list-stack-press/ListStackPress.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