弹簧动画 spring-animation

弹簧动画技能用于在Remotion视频制作中实现弹簧物理效果,创造自然、有弹性的运动,包括弹跳入场、弹性轨迹、过冲稳定等。关键词:弹簧动画、Remotion、视频制作、物理模拟、弹跳效果、动画技能、前端开发、影视后期、弹簧物理、动态图形。

前端开发 0 次安装 0 次浏览 更新于 3/7/2026

名称: 弹簧动画 描述: Remotion弹簧物理用于动态图形视频制作。弹性入场、弹性轨迹、编排序列、物理预设以及单纯使用interpolate()无法实现的有机运动模式。 元数据: 标签: 弹簧, remotion, 动态图形, 动画, 视频, 物理, 弹跳, 弹性, 轨迹, 错开

何时使用

在创建需要弹簧物理的Remotion视频合成时使用此技能——自然的、有机的运动,带有弹跳、过冲和弹性稳定。

当您需要时使用弹簧:

  • 弹跳/弹性入场(过冲 + 稳定)
  • 有机减速(不是线性,不是缓动——物理建模)
  • 带有弹簧物理的错开轨迹每个元素
  • 数字计数器过冲然后稳定
  • 缩放/旋转带有自然重量和惯性
  • 入场 + 退场动画带有弹簧数学(进 - 出
  • 多属性编排每个属性不同的弹簧配置

当以下情况时使用Remotion原生interpolate():

  • 线性或缓动运动无弹跳(淡入淡出、滑动、擦拭)
  • 精确时间控制(必须在帧N精确结束)
  • 剪辑路径动画
  • 进度条 / 确定性计数器

当以下情况时使用GSAP(gsap-animation技能):

  • 文本拆分(SplitText: 字符/单词/行带有遮罩)
  • SVG笔画绘制(DrawSVG)
  • SVG变形(MorphSVG)
  • 复杂时间线编排带有标签和位置参数
  • 混排文本解码效果
  • 注册可重用效果

注意: @react-spring/web与Remotion不兼容(它在内部使用requestAnimationFrame)。此技能使用Remotion的原生spring()函数,以帧确定性的方式提供相同的物理模型。


核心API

spring()

基于弹簧物理模拟返回一个从0到1的值(在低阻尼下可以过冲超过1)。

import { spring, useCurrentFrame, useVideoConfig } from 'remotion';

const frame = useCurrentFrame();
const { fps } = useVideoConfig();

const value = spring({
  frame,
  fps,
  config: {
    damping: 10,     // 1-200: 更高 = 更少弹跳
    stiffness: 100,  // 1-200: 更高 = 更快快照
    mass: 1,         // 0.1-5: 更高 = 更多惯性
  },
});

配置参数

参数 范围 默认值 效果
damping 1-200 10 阻力。低 = 弹跳,高 = 平滑
stiffness 1-200 100 快照速度。高 = 快,低 = 慢
mass 0.1-5 1 重量/惯性。高 = 迟钝,低 = 轻快
overshootClamping 布尔 false 在目标处钳制(无过冲)

附加选项

选项 类型 效果
delay 数字 延迟开始N帧(延迟结束前返回0)
durationInFrames 数字 强制弹簧在N帧内稳定
reverse 布尔 从1到0动画
from 数字 起始值(默认0)
to 数字 结束值(默认1)

measureSpring()

计算弹簧配置需要多少帧来稳定。对于<Sequence>和合成持续时间至关重要。

import { measureSpring } from 'remotion';

const frames = measureSpring({
  fps: 30,
  config: { damping: 10, stiffness: 100 },
}); // => 稳定前的帧数

物理预设

// src/spring-presets.ts
import { SpringConfig } from 'remotion';

export const SPRING = {
  // 平滑,无弹跳——微妙揭示,背景运动
  smooth: { damping: 200 } as Partial<SpringConfig>,

  // 快照,最小弹跳——UI元素,干净入场
  snappy: { damping: 20, stiffness: 200 } as Partial<SpringConfig>,

  // 弹跳——活泼入场,吸引注意力
  bouncy: { damping: 8 } as Partial<SpringConfig>,

  // 重,慢——戏剧性揭示,重量物体
  heavy: { damping: 15, stiffness: 80, mass: 2 } as Partial<SpringConfig>,

  // 摇摆——弹性,卡通式过冲
  wobbly: { damping: 4, stiffness: 80 } as Partial<SpringConfig>,

  // 僵硬——快速快照带微小弹跳
  stiff: { damping: 15, stiffness: 300 } as Partial<SpringConfig>,

  // 温和——慢,梦幻,有机
  gentle: { damping: 20, stiffness: 40, mass: 1.5 } as Partial<SpringConfig>,

  // 糖浆——非常慢,重,几乎不弹跳
  molasses: { damping: 25, stiffness: 30, mass: 3 } as Partial<SpringConfig>,

  // 弹出——强过冲用于缩放效果
  pop: { damping: 6, stiffness: 150 } as Partial<SpringConfig>,

  // 橡胶——夸张弹性弹跳
  rubber: { damping: 3, stiffness: 100, mass: 0.5 } as Partial<SpringConfig>,
} as const;

预设视觉参考

预设 弹跳 速度 感觉 最佳用途
smooth 黄油 背景,微妙揭示
snappy 最小 清脆 UI元素,按钮
bouncy 活泼 标题,图标,注意力
heavy 重量 戏剧性揭示,大物体
wobbly 极端 卡通 活泼,幽默
stiff 微小 非常快 机械 数据可视化,精确运动
gentle 最小 梦幻 奢华,平静,有机
molasses 几乎无 非常慢 电影,悬念
pop 有力 缩放入,徽章,图标弹出
rubber 极端 弹性 夸张,卡通,有趣

1. 弹簧入场模式

基本弹簧入场

import { spring, interpolate, useCurrentFrame, useVideoConfig, AbsoluteFill } from 'remotion';
import { SPRING } from './spring-presets';

const SpringEntrance: React.FC<{
  children: React.ReactNode;
  preset?: keyof typeof SPRING;
  delay?: number;
}> = ({ children, preset = 'bouncy', delay = 0 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({ frame, fps, delay, config: SPRING[preset] });
  const translateY = interpolate(progress, [0, 1], [60, 0]);

  return (
    <AbsoluteFill style={{
      opacity: progress,
      transform: `translateY(${translateY}px)`,
    }}>
      {children}
    </AbsoluteFill>
  );
};

缩放弹出

const ScalePop: React.FC<{
  children: React.ReactNode;
  delay?: number;
}> = ({ children, delay = 0 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // pop 预设过冲超过1,创造自然缩放弹跳
  const scale = spring({ frame, fps, delay, config: SPRING.pop });
  const opacity = spring({ frame, fps, delay, config: SPRING.smooth });

  return (
    <div style={{
      transform: `scale(${scale})`,
      opacity,
    }}>
      {children}
    </div>
  );
};

入场 + 退场(弹簧数学)

const EnterExit: React.FC<{
  children: React.ReactNode;
  enterDelay?: number;
  exitBeforeEnd?: number; // 在合成结束前开始退场的帧数
}> = ({ children, enterDelay = 0, exitBeforeEnd = 30 }) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  const enter = spring({ frame, fps, delay: enterDelay, config: SPRING.bouncy });
  const exit = spring({
    frame, fps,
    delay: durationInFrames - exitBeforeEnd,
    config: SPRING.snappy,
  });

  const scale = enter - exit; // 0 -> 1 -> 0
  const opacity = enter - exit;

  return (
    <div style={{ transform: `scale(${scale})`, opacity }}>
      {children}
    </div>
  );
};

2. 轨迹 / 错开模式

弹簧轨迹(错开入场)

模仿React Spring的useTrail——每个元素以帧延迟入场。

const SpringTrail: React.FC<{
  items: React.ReactNode[];
  staggerFrames?: number;
  preset?: keyof typeof SPRING;
  direction?: 'up' | 'down' | 'left' | 'right';
}> = ({ items, staggerFrames = 4, preset = 'bouncy', direction = 'up' }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const getOffset = (progress: number) => {
    const distance = 50;
    const remaining = interpolate(progress, [0, 1], [distance, 0]);
    switch (direction) {
      case 'up': return { transform: `translateY(${remaining}px)` };
      case 'down': return { transform: `translateY(${-remaining}px)` };
      case 'left': return { transform: `translateX(${remaining}px)` };
      case 'right': return { transform: `translateX(${-remaining}px)` };
    }
  };

  return (
    <>
      {items.map((item, i) => {
        const delay = i * staggerFrames;
        const progress = spring({ frame, fps, delay, config: SPRING[preset] });
        return (
          <div key={i} style={{ opacity: progress, ...getOffset(progress) }}>
            {item}
          </div>
        );
      })}
    </>
  );
};

字符轨迹(文本动画)

手动字符拆分带弹簧错开。对于高级文本拆分(遮罩揭示,行包装),使用gsap-animation技能代替。

const CharacterTrail: React.FC<{
  text: string;
  staggerFrames?: number;
  preset?: keyof typeof SPRING;
  fontSize?: number;
}> = ({ text, staggerFrames = 2, preset = 'pop', fontSize = 80 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return (
    <div style={{ display: 'flex', justifyContent: 'center', overflow: 'hidden' }}>
      {text.split('').map((char, i) => {
        const delay = i * staggerFrames;
        const progress = spring({ frame, fps, delay, config: SPRING[preset] });
        const translateY = interpolate(progress, [0, 1], [fontSize, 0]);

        return (
          <span key={i} style={{
            display: 'inline-block',
            fontSize,
            fontWeight: 'bold',
            color: '#fff',
            opacity: progress,
            transform: `translateY(${translateY}px)`,
            whiteSpace: 'pre',
          }}>
            {char === ' ' ? '\u00A0' : char}
          </span>
        );
      })}
    </div>
  );
};

单词轨迹

const WordTrail: React.FC<{
  text: string;
  staggerFrames?: number;
  preset?: keyof typeof SPRING;
  fontSize?: number;
}> = ({ text, staggerFrames = 5, preset = 'bouncy', fontSize = 64 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const words = text.split(' ');

  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3em', justifyContent: 'center' }}>
      {words.map((word, i) => {
        const delay = i * staggerFrames;
        const progress = spring({ frame, fps, delay, config: SPRING[preset] });
        const scale = spring({ frame, fps, delay, config: SPRING.pop });

        return (
          <span key={i} style={{
            display: 'inline-block',
            fontSize,
            fontWeight: 'bold',
            color: '#fff',
            opacity: progress,
            transform: `scale(${scale})`,
          }}>
            {word}
          </span>
        );
      })}
    </div>
  );
};

网格错开(中心向外)

const GridStagger: React.FC<{
  items: React.ReactNode[];
  columns: number;
  cellSize?: number;
  gap?: number;
}> = ({ items, columns, cellSize = 120, gap = 16 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const rows = Math.ceil(items.length / columns);
  const centerCol = (columns - 1) / 2;
  const centerRow = (rows - 1) / 2;

  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
      gap,
    }}>
      {items.map((item, i) => {
        const col = i % columns;
        const row = Math.floor(i / columns);
        // 从中心的距离决定延迟
        const dist = Math.sqrt((col - centerCol) ** 2 + (row - centerRow) ** 2);
        const delay = Math.round(dist * 4);
        const progress = spring({ frame, fps, delay, config: SPRING.pop });

        return (
          <div key={i} style={{
            width: cellSize, height: cellSize,
            opacity: progress,
            transform: `scale(${progress})`,
          }}>
            {item}
          </div>
        );
      })}
    </div>
  );
};

3. 链 / 序列模式

弹簧链(顺序动画)

模仿React Spring的useChain——使用measureSpring计时按顺序触发动画。

import { spring, measureSpring, interpolate, useCurrentFrame, useVideoConfig } from 'remotion';

const SpringChain: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 步骤1: 容器缩放入场
  const step1Config = SPRING.bouncy;
  const step1 = spring({ frame, fps, config: step1Config });
  const step1Duration = measureSpring({ fps, config: step1Config });

  // 步骤2: 标题淡入上升(在步骤1完成80%时开始)
  const step2Delay = Math.round(step1Duration * 0.8);
  const step2Config = SPRING.snappy;
  const step2 = spring({ frame, fps, delay: step2Delay, config: step2Config });
  const step2Duration = measureSpring({ fps, config: step2Config });

  // 步骤3: 副标题出现(在步骤2完成时开始)
  const step3Delay = step2Delay + step2Duration;
  const step3 = spring({ frame, fps, delay: step3Delay, config: SPRING.gentle });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      {/* 容器 */}
      <div style={{
        transform: `scale(${step1})`,
        opacity: step1,
        background: '#1e293b',
        padding: 60,
        borderRadius: 24,
        textAlign: 'center',
      }}>
        {/* 标题 */}
        <h1 style={{
          fontSize: 72, fontWeight: 'bold', color: '#fff',
          opacity: step2,
          transform: `translateY(${interpolate(step2, [0, 1], [30, 0])}px)`,
        }}>弹簧链</h1>
        {/* 副标题 */}
        <p style={{
          fontSize: 28, color: 'rgba(255,255,255,0.7)', marginTop: 16,
          opacity: step3,
          transform: `translateY(${interpolate(step3, [0, 1], [20, 0])}px)`,
        }}>顺序弹簧编排</p>
      </div>
    </AbsoluteFill>
  );
};

useSpringChain 钩子

可重用钩子用于链接多个弹簧带重叠控制。

import { spring, measureSpring, SpringConfig, useCurrentFrame, useVideoConfig } from 'remotion';

type ChainStep = {
  config: Partial<SpringConfig>;
  overlap?: number; // 0-1, 与前一步骤重叠多少(默认0)
};

function useSpringChain(steps: ChainStep[]) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  let currentDelay = 0;
  return steps.map((step, i) => {
    if (i > 0) {
      const prevDuration = measureSpring({ fps, config: steps[i - 1].config });
      const overlap = step.overlap ?? 0;
      currentDelay += Math.round(prevDuration * (1 - overlap));
    }
    return spring({ frame, fps, delay: currentDelay, config: step.config });
  });
}

// 用法:
const [container, title, subtitle, cta] = useSpringChain([
  { config: SPRING.bouncy },
  { config: SPRING.snappy, overlap: 0.2 },
  { config: SPRING.gentle, overlap: 0.3 },
  { config: SPRING.pop, overlap: 0.1 },
]);

4. 多属性弹簧

每个属性不同物理

const MultiPropertySpring: React.FC<{ delay?: number }> = ({ delay = 0 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 位置: 快照(快速到达)
  const position = spring({ frame, fps, delay, config: SPRING.snappy });
  // 缩放: 弹跳(过冲然后稳定)
  const scale = spring({ frame, fps, delay, config: SPRING.bouncy });
  // 旋转: 摇摆(弹性摆动)
  const rotation = spring({ frame, fps, delay, config: SPRING.wobbly });
  // 不透明度: 平滑(无弹跳)
  const opacity = spring({ frame, fps, delay, config: SPRING.smooth });

  const translateX = interpolate(position, [0, 1], [-300, 0]);
  const rotate = interpolate(rotation, [0, 1], [-15, 0]);

  return (
    <div style={{
      transform: `translateX(${translateX}px) scale(${scale}) rotate(${rotate}deg)`,
      opacity,
    }}>
      多属性
    </div>
  );
};

5. 弹簧计数器

带弹簧物理的数字计数器——过冲目标然后稳定。

const SpringCounter: React.FC<{
  endValue: number;
  prefix?: string;
  suffix?: string;
  preset?: keyof typeof SPRING;
  delay?: number;
}> = ({ endValue, prefix = '', suffix = '', preset = 'bouncy', delay = 0 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({ frame, fps, delay, config: SPRING[preset] });
  // 带弹跳配置,进度在稳定前过冲超过1.0
  // 这意味着计数器短暂显示数字 > endValue,然后稳定
  const value = Math.round(progress * endValue);

  return (
    <div style={{
      fontSize: 96, fontWeight: 'bold', color: '#fff',
      fontVariantNumeric: 'tabular-nums',
    }}>
      {prefix}{value.toLocaleString()}{suffix}
    </div>
  );
};

与线性计数器比较:

  • interpolate() 计数器: 平滑达到精确目标,无过冲
  • 弹簧计数器: 过冲然后稳定——感觉更有能量和活力

6. 3D变换模式

弹簧卡片翻转

const SpringCardFlip: React.FC<{
  frontContent: React.ReactNode;
  backContent: React.ReactNode;
  flipDelay?: number;
}> = ({ frontContent, backContent, flipDelay = 15 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const flipProgress = spring({
    frame, fps, delay: flipDelay,
    config: { damping: 15, stiffness: 80 }, // 慢,重量翻转
  });
  const rotateY = interpolate(flipProgress, [0, 1], [0, 180]);

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div style={{ perspective: 800 }}>
        <div style={{
          width: 500, height: 320, position: 'relative',
          transformStyle: 'preserve-3d',
          transform: `rotateY(${rotateY}deg)`,
        }}>
          <div style={{
            position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
            background: '#1e293b', borderRadius: 16,
            display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32,
          }}>{frontContent}</div>
          <div style={{
            position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
            background: '#3b82f6', borderRadius: 16, transform: 'rotateY(180deg)',
            display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32,
          }}>{backContent}</div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

透视倾斜

const PerspectiveTilt: React.FC<{
  children: React.ReactNode;
  rotateX?: number;
  rotateY?: number;
  delay?: number;
}> = ({ children, rotateX = -20, rotateY = 15, delay = 0 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = spring({ frame, fps, delay, config: SPRING.heavy });
  const rx = interpolate(progress, [0, 1], [rotateX, 0]);
  const ry = interpolate(progress, [0, 1], [rotateY, 0]);
  const translateZ = interpolate(progress, [0, 1], [-200, 0]);

  return (
    <div style={{
      perspective: 1000,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      <div style={{
        transform: `perspective(1000px) rotateX(${rx}deg) rotateY(${ry}deg) translateZ(${translateZ}px)`,
        opacity: progress,
      }}>
        {children}
      </div>
    </div>
  );
};

7. 弹簧过渡

带弹簧的交叉淡化

const SpringCrossfade: React.FC<{
  outgoing: React.ReactNode;
  incoming: React.ReactNode;
  switchFrame: number;
}> = ({ outgoing, incoming, switchFrame }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const outOpacity = frame < switchFrame ? 1 : 1 - spring({
    frame: frame - switchFrame, fps, config: SPRING.smooth,
  });
  const inOpacity = frame < switchFrame ? 0 : spring({
    frame: frame - switchFrame, fps, config: SPRING.smooth,
  });
  const inScale = frame < switchFrame ? 0.95 : interpolate(
    spring({ frame: frame - switchFrame, fps, config: SPRING.bouncy }),
    [0, 1], [0.95, 1]
  );

  return (
    <AbsoluteFill>
      <AbsoluteFill style={{ opacity: outOpacity }}>{outgoing}</AbsoluteFill>
      <AbsoluteFill style={{ opacity: inOpacity, transform: `scale(${inScale})` }}>
        {incoming}
      </AbsoluteFill>
    </AbsoluteFill>
  );
};

带弹簧的滑动过渡

const SpringSlide: React.FC<{
  outgoing: React.ReactNode;
  incoming: React.ReactNode;
  switchFrame: number;
  direction?: 'left' | 'right' | 'up' | 'down';
}> = ({ outgoing, incoming, switchFrame, direction = 'left' }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const progress = frame < switchFrame ? 0 : spring({
    frame: frame - switchFrame, fps, config: SPRING.snappy,
  });

  const getTransform = (isOutgoing: boolean) => {
    const offset = isOutgoing ? interpolate(progress, [0, 1], [0, -100]) : interpolate(progress, [0, 1], [100, 0]);
    switch (direction) {
      case 'left': return `translateX(${offset}%)`;
      case 'right': return `translateX(${-offset}%)`;
      case 'up': return `translateY(${offset}%)`;
      case 'down': return `translateY(${-offset}%)`;
    }
  };

  return (
    <AbsoluteFill style={{ overflow: 'hidden' }}>
      <AbsoluteFill style={{ transform: getTransform(true) }}>{outgoing}</AbsoluteFill>
      <AbsoluteFill style={{ transform: getTransform(false) }}>{incoming}</AbsoluteFill>
    </AbsoluteFill>
  );
};

8. 模板

弹簧标题卡

const SpringTitleCard: React.FC<{
  title: string;
  subtitle?: string;
  accent?: string;
}> = ({ title, subtitle, accent = '#3b82f6' }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  // 背景形状
  const bgScale = spring({ frame, fps, config: SPRING.heavy });
  // 标题单词错开
  const words = title.split(' ');
  // 分隔线
  const dividerWidth = spring({ frame, fps, delay: 8, config: SPRING.snappy });
  // 副标题
  const subtitleProgress = spring({ frame, fps, delay: 15, config: SPRING.gentle });

  return (
    <AbsoluteFill style={{
      background: '#0f172a',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      {/* 强调圆圈 */}
      <div style={{
        position: 'absolute', width: 300, height: 300, borderRadius: '50%',
        background: `${accent}20`, transform: `scale(${bgScale})`,
      }} />

      <div style={{ textAlign: 'center', position: 'relative', zIndex: 1 }}>
        {/* 带单词错开的标题 */}
        <div style={{ display: 'flex', gap: '0.3em', justifyContent: 'center', flexWrap: 'wrap' }}>
          {words.map((word, i) => {
            const delay = i * 4;
            const progress = spring({ frame, fps, delay, config: SPRING.pop });
            const y = interpolate(progress, [0, 1], [40, 0]);
            return (
              <span key={i} style={{
                fontSize: 80, fontWeight: 'bold', color: '#fff',
                display: 'inline-block',
                opacity: progress,
                transform: `translateY(${y}px) scale(${progress})`,
              }}>{word}</span>
            );
          })}
        </div>

        {/* 分隔线 */}
        <div style={{
          width: 80, height: 3, background: accent, margin: '20px auto',
          transform: `scaleX(${dividerWidth})`, transformOrigin: 'center',
        }} />

        {/* 副标题 */}
        {subtitle && (
          <p style={{
            fontSize: 28, color: 'rgba(255,255,255,0.7)',
            opacity: subtitleProgress,
            transform: `translateY(${interpolate(subtitleProgress, [0, 1], [15, 0])}px)`,
          }}>{subtitle}</p>
        )}
      </div>
    </AbsoluteFill>
  );
};

弹簧下三分之一

const SpringLowerThird: React.FC<{
  name: string;
  title: string;
  accent?: string;
  hold?: number;
}> = ({ name, title, accent = '#3b82f6', hold = 90 }) => {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  // 入场
  const barIn = spring({ frame, fps, config: SPRING.snappy });
  const nameIn = spring({ frame, fps, delay: 6, config: SPRING.bouncy });
  const titleIn = spring({ frame, fps, delay: 10, config: SPRING.gentle });

  // 退场(弹簧数学减法)
  const exitDelay = durationInFrames - 20;
  const barOut = spring({ frame, fps, delay: exitDelay, config: SPRING.stiff });
  const nameOut = spring({ frame, fps, delay: exitDelay - 4, config: SPRING.stiff });
  const titleOut = spring({ frame, fps, delay: exitDelay - 8, config: SPRING.stiff });

  return (
    <AbsoluteFill>
      <div style={{ position: 'absolute', bottom: 80, left: 60 }}>
        {/* 条 */}
        <div style={{
          background: accent, padding: '12px 24px', borderRadius: 4,
          transform: `scaleX(${barIn - barOut})`,
          transformOrigin: 'left',
          opacity: barIn - barOut,
        }}>
          <div style={{
            fontSize: 28, fontWeight: 'bold', color: '#fff',
            opacity: nameIn - nameOut,
            transform: `translateX(${interpolate(nameIn - nameOut, [0, 1], [-20, 0])}px)`,
          }}>{name}</div>
          <div style={{
            fontSize: 18, color: 'rgba(255,255,255,0.8)',
            opacity: titleIn - titleOut,
            transform: `translateX(${interpolate(titleIn - titleOut, [0, 1], [-15, 0])}px)`,
          }}>{title}</div>
        </div>
      </div>
    </AbsoluteFill>
  );
};

弹簧功能网格

const SpringFeatureGrid: React.FC<{
  features: Array<{ icon: string; label: string }>;
  columns?: number;
}> = ({ features, columns = 3 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return (
    <AbsoluteFill style={{
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      background: '#0f172a',
    }}>
      <div style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${columns}, 200px)`,
        gap: 32,
      }}>
        {features.map(({ icon, label }, i) => {
          const delay = i * 5;
          const scale = spring({ frame, fps, delay, config: SPRING.pop });
          const opacity = spring({ frame, fps, delay, config: SPRING.smooth });

          return (
            <div key={i} style={{
              textAlign: 'center', padding: 24,
              background: 'rgba(255,255,255,0.05)', borderRadius: 16,
              transform: `scale(${scale})`, opacity,
            }}>
              <div style={{ fontSize: 48 }}>{icon}</div>
              <div style={{ fontSize: 18, color: '#fff', marginTop: 12 }}>{label}</div>
            </div>
          );
        })}
      </div>
    </AbsoluteFill>
  );
};

弹簧结尾

const SpringOutro: React.FC<{
  headline: string;
  tagline?: string;
  ctaText?: string;
  accent?: string;
}> = ({ headline, tagline, ctaText, accent = '#3b82f6' }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const headlineProgress = spring({ frame, fps, config: SPRING.heavy });
  const taglineProgress = spring({ frame, fps, delay: 12, config: SPRING.gentle });
  const ctaProgress = spring({ frame, fps, delay: 20, config: SPRING.pop });

  return (
    <AbsoluteFill style={{
      background: '#0f172a',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      <div style={{ textAlign: 'center' }}>
        <h1 style={{
          fontSize: 72, fontWeight: 'bold', color: '#fff',
          opacity: headlineProgress,
          transform: `scale(${headlineProgress})`,
        }}>{headline}</h1>

        {tagline && (
          <p style={{
            fontSize: 28, color: 'rgba(255,255,255,0.6)', marginTop: 16,
            opacity: taglineProgress,
            transform: `translateY(${interpolate(taglineProgress, [0, 1], [15, 0])}px)`,
          }}>{tagline}</p>
        )}

        {ctaText && (
          <div style={{
            display: 'inline-block', marginTop: 32,
            background: accent, padding: '16px 40px', borderRadius: 8,
            fontSize: 24, fontWeight: 'bold', color: '#fff',
            transform: `scale(${ctaProgress})`, opacity: ctaProgress,
          }}>{ctaText}</div>
        )}
      </div>
    </AbsoluteFill>
  );
};

9. 实用工具: useSpringTrail

用于轨迹动画的可重用钩子。

import { spring, SpringConfig, useCurrentFrame, useVideoConfig } from 'remotion';

function useSpringTrail(
  count: number,
  config: Partial<SpringConfig>,
  staggerFrames = 4,
  baseDelay = 0,
) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  return Array.from({ length: count }, (_, i) => {
    const delay = baseDelay + i * staggerFrames;
    return spring({ frame, fps, delay, config });
  });
}

// 用法:
const trail = useSpringTrail(5, SPRING.pop, 4);
// trail = [0.98, 0.85, 0.5, 0.1, 0] -- 每个项目在不同进度

10. 实用工具: useSpringEnterExit

用于入场 + 退场模式的可重用钩子。

function useSpringEnterExit(
  enterConfig: Partial<SpringConfig>,
  exitConfig: Partial<SpringConfig>,
  enterDelay = 0,
  exitBeforeEnd = 30,
) {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();

  const enter = spring({ frame, fps, delay: enterDelay, config: enterConfig });
  const exit = spring({
    frame, fps,
    delay: durationInFrames - exitBeforeEnd,
    config: exitConfig,
  });

  return enter - exit;
}

// 用法:
const progress = useSpringEnterExit(SPRING.bouncy, SPRING.stiff, 0, 25);

11. 与其他技能结合

const CombinedScene: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const bgOpacity = spring({ frame, fps, config: SPRING.smooth });

  return (
    <AbsoluteFill>
      {/* react-animation: 视觉氛围 */}
      <div style={{ opacity: bgOpacity }}>
        <Aurora colorStops={['#3A29FF', '#FF94B4']} />
      </div>

      {/* spring-animation: 弹跳标题入场 */}
      <SpringTitleCard title="自然运动" subtitle="物理驱动的美" />

      {/* gsap-animation: 文本拆分,弹簧无法实现 */}
      <GSAPTextReveal text="高级排版" />
    </AbsoluteFill>
  );
};
技能 最佳用途
弹簧动画 弹跳入场、弹性轨迹、有机物理、过冲效果、弹簧计数器
gsap-animation 文本拆分(SplitText)、SVG绘制(DrawSVG)、SVG变形、复杂时间线标签
react-animation 视觉背景(Aurora, Silk, Particles)、着色器效果

12. 合成注册

export const RemotionRoot: React.FC = () => (
  <>
    <Composition id="SpringTitleCard" component={SpringTitleCard}
      durationInFrames={90} fps={30} width={1920} height={1080}
      defaultProps={{ title: '弹簧物理', subtitle: '视频的自然运动' }} />
    <Composition id="SpringLowerThird" component={SpringLowerThird}
      durationInFrames={180} fps={30} width={1920} height={1080}
      defaultProps={{ name: 'Jane Smith', title: '创意总监' }} />
    <Composition id="SpringFeatureGrid" component={SpringFeatureGrid}
      durationInFrames={90} fps={30} width={1920} height={1080}
      defaultProps={{ features: [
        { icon: '🚀', label: '快速' },
        { icon: '🎯', label: '精确' },
        { icon: '✨', label: '美丽' },
      ]}} />
    <Composition id="SpringOutro" component={SpringOutro}
      durationInFrames={120} fps={30} width={1920} height={1080}
      defaultProps={{ headline: '开始使用', tagline: '今天免费尝试', ctaText: '注册 →' }} />
  </>
);

13. 渲染

# 默认 MP4
npx remotion render src/index.ts SpringTitleCard --output out/title.mp4

# 高质量
npx remotion render src/index.ts SpringTitleCard --codec h264 --crf 15

# GIF
npx remotion render src/index.ts SpringTitleCard --codec gif --every-nth-frame 2

# ProRes 用于编辑
npx remotion render src/index.ts SpringTitleCard --codec prores --prores-profile 4444