mcpField
G
gsap-animation

gsap-animation

github.com/noteditクリエイティブ・メディア340317 回インストール34 週間前に更新
リポジトリ

gsap-animation について

Remotionで動画を制作する人向けのGSAPアニメーションスキルです。GSAPのタイムラインをRemotionのフレームに同期し、テキスト、SVG、3D、トランジションを扱います。

主な機能

  1. 01フレーム単位のGSAPタイムライン制御
  2. 02テキスト分割とスクランブル演出
  3. 03SVGの変形、描画、パスアニメーション
  4. 043D変換とシーントランジション
  5. 05再利用可能なアニメーションパターン

活用シーン

  1. 01文字や行ごとのタイトル表示
  2. 02SVGロゴの描画アニメーション
  3. 03動画シーンのトランジション構成
  4. 04画面内のカーソルクリック演出

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/notedit/happy-skills --skill gsap-animation

インストール後、Claude Code で/gsap-animationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

rules/
plugin-guide.md
remotion-integration.md
timing-conventions.md
video-blueprints.md

詳細

使用する場面

Remotion に組み込まれている interpolate() や spring() では足りない、GSAP の高度なアニメーション機能を必要とする Remotion のビデオコンポジションを作成する場合に、このスキルを使用してください。

次のような場合は GSAP を使用してください:

  • 複雑なタイムラインの構成(ネスト、ラベル、"-=0.5" などの位置パラメーター)
  • テキスト分割アニメーション(SplitText:マスク表示を使った文字/単語/行のアニメーション)
  • SVG のシェイプモーフィング(MorphSVG)、ストローク描画(DrawSVG)、パス追従(MotionPath)
  • 高度なイージング(SVG パスから作成する CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle)
  • グリッド、中央/端への分布を指定したスタッガー
  • 文字のスクランブル/デコード効果(ScrambleText)
  • gsap.registerEffect() による再利用可能な名前付きエフェクト

次のような場合は Remotion 標準の interpolate() を使用してください:

  • 単一プロパティのシンプルなアニメーション(フェード、スライド、スケール) -- これらに GSAP を使用しないでください
  • 数値カウンター/プログレスバー -- 純粋な計算で、タイムラインは不要です
  • 標準的なイージング曲線
  • ばねの物理演算(spring())

GSAP のライセンス: Webflow による 2024 年の買収以降、すべてのプラグイン(SplitText、MorphSVG、DrawSVG など)が完全無料です。


セットアップ

# In a Remotion project
npm install gsap
// src/gsap-setup.ts -- import once at entry point
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { MorphSVGPlugin } from 'gsap/MorphSVGPlugin';
import { DrawSVGPlugin } from 'gsap/DrawSVGPlugin';
import { MotionPathPlugin } from 'gsap/MotionPathPlugin';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import { CustomEase } from 'gsap/CustomEase';
import { CustomBounce } from 'gsap/CustomBounce';
import { CustomWiggle } from 'gsap/CustomWiggle';

gsap.registerPlugin(
  SplitText, MorphSVGPlugin, DrawSVGPlugin, MotionPathPlugin,
  ScrambleTextPlugin, CustomEase, CustomBounce, CustomWiggle,
);

export { gsap };

コアフック:useGSAPTimeline

GSAP と Remotion をつなぐブリッジです。一時停止状態のタイムラインを作成し、各フレームで frame / fps の位置にシークします。

import { useCurrentFrame, useVideoConfig } from 'remotion';
import gsap from 'gsap';
import { useRef, useEffect } from 'react';

function useGSAPTimeline(
  buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const containerRef = useRef<HTMLDivElement>(null);
  const tlRef = useRef<gsap.core.Timeline | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;
    const ctx = gsap.context(() => {
      const tl = gsap.timeline({ paused: true });
      buildTimeline(tl, containerRef.current!);
      tlRef.current = tl;
    }, containerRef);
    return () => { ctx.revert(); tlRef.current = null; };
  }, []);

  useEffect(() => {
    if (tlRef.current) tlRef.current.seek(frame / fps);
  }, [frame, fps]);

  return containerRef;
}

SplitText の場合(フォントの読み込みが必要):

import { delayRender, continueRender } from 'remotion';

function useGSAPWithFonts(
  buildTimeline: (tl: gsap.core.Timeline, container: HTMLDivElement) => void
) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const containerRef = useRef<HTMLDivElement>(null);
  const tlRef = useRef<gsap.core.Timeline | null>(null);
  const [handle] = useState(() => delayRender());

  useEffect(() => {
    document.fonts.ready.then(() => {
      if (!containerRef.current) return;
      const ctx = gsap.context(() => {
        const tl = gsap.timeline({ paused: true });
        buildTimeline(tl, containerRef.current!);
        tlRef.current = tl;
      }, containerRef);
      continueRender(handle);
      return () => { ctx.revert(); };
    });
  }, []);

  useEffect(() => {
    if (tlRef.current) tlRef.current.seek(frame / fps);
  }, [frame, fps]);

  return containerRef;
}

1. テキストアニメーション

SplitText の表示(文字/単語/行)
const TextReveal: React.FC<{ text: string }> = ({ text }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const split = SplitText.create(container.querySelector('.heading')!, {
      type: 'chars,words,lines', mask: 'lines',
    });
    tl.from(split.chars, {
      y: 100, opacity: 0, duration: 0.6, stagger: 0.03, ease: 'power2.out',
    });
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef}>
        <h1 className="heading" style={{ fontSize: 80, fontWeight: 'bold' }}>{text}</h1>
      </div>
    </AbsoluteFill>
  );
};

パターン:

パターンSplitText の設定アニメーション
行の表示type: "lines", mask: "lines"from lines: { y: "100%" }
文字のカスケードtype: "chars"from chars: { y: 50, opacity: 0, rotationX: -90 }
単語の拡大type: "words"from words: { scale: 0, opacity: 0 }
文字+色type: "chars".from(chars, { y: 50 }).to(chars, { color: "#f00" })
ScrambleText(デコード効果)

決定性に関する警告: ScrambleText は内部で文字をランダムに選択します。レンダリング間でフレーム単位の再現性を保証するには、レンダリング時に --concurrency=1 を使用してください。

const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('.text')!, {
    duration: 2,
    scrambleText: { text: 'DECODED', chars: '01', revealDelay: 0.5, speed: 0.3 },
  });
});

文字セット: "upperCase"、"lowerCase"、"upperAndLowerCase"、"01"、またはカスタム文字列。

テキストのハイライトボックス

色付きの長方形を特定の単語の背後で拡大します。単語単位の位置指定には SplitText を使用し、z-index を低く設定した <div> ボックスを絶対配置します。

const TextHighlightBox: React.FC<{
  text: string;
  highlights: Array<{ wordIndex: number; color: string }>;
  highlightDelay?: number;
  highlightStagger?: number;
}> = ({ text, highlights, highlightDelay = 0.5, highlightStagger = 0.3 }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const textEl = container.querySelector('.highlight-text')!;
    const split = SplitText.create(textEl, { type: 'words' });

    // Entrance: words fade in
    tl.from(split.words, {
      y: 20, opacity: 0, duration: 0.5, stagger: 0.05, ease: 'power2.out',
    });

    // Highlight boxes scale in behind target words
    highlights.forEach(({ wordIndex, color }, i) => {
      const word = split.words[wordIndex] as HTMLElement;
      if (!word) return;

      const box = document.createElement('div');
      Object.assign(box.style, {
        position: 'absolute',
        left: `${word.offsetLeft - 4}px`,
        top: `${word.offsetTop - 2}px`,
        width: `${word.offsetWidth + 8}px`,
        height: `${word.offsetHeight + 4}px`,
        background: color,
        borderRadius: '4px',
        zIndex: '-1',
        transformOrigin: 'left center',
        transform: 'scaleX(0)',
      });
      textEl.appendChild(box);

      tl.to(box, {
        scaleX: 1, duration: 0.3, ease: 'power2.out',
      }, highlightDelay + i * highlightStagger);
    });
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef}>
        <p className="highlight-text" style={{
          fontSize: 64, fontWeight: 'bold', color: '#fff',
          position: 'relative', maxWidth: '70%', lineHeight: 1.2,
        }}>{text}</p>
      </div>
    </AbsoluteFill>
  );
};

Props: highlights は、特定の単語を対象にする { wordIndex, color } の配列です(SplitText の単語インデックスは 0 から始まります)。


2. SVG アニメーション

MorphSVG(シェイプモーフィング)
const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('#path')!, {
    morphSVG: { shape: '#target-path', type: 'rotational', map: 'size' },
    duration: 1.5, ease: 'power2.inOut',
  });
});
オプション値
type"linear"(デフォルト)、"rotational"
map"size"、"position"、"complexity"
shapeIndex点の位置合わせのオフセットを指定する整数
DrawSVG(ストロークアニメーション)
const containerRef = useGSAPTimeline((tl, container) => {
  const paths = container.querySelectorAll('.logo-path');
  tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.2, ease: 'power2.inOut' })
    .to(paths, { fill: '#ffffff', duration: 0.5 }, '-=0.3');
});
パターンDrawSVG の値
何もない状態から描画0
中央から描画"50% 50%"
セグメントを表示"20% 80%"
消去"100% 100%"
MotionPath(パス追従)
const containerRef = useGSAPTimeline((tl, container) => {
  tl.to(container.querySelector('.element')!, {
    motionPath: {
      path: container.querySelector('#svg-path') as SVGPathElement,
      align: container.querySelector('#svg-path') as SVGPathElement,
      alignOrigin: [0.5, 0.5],
      autoRotate: true,
    },
    duration: 3, ease: 'power1.inOut',
  });
});

3. 3D トランスフォームのパターン

パフォーマンスに関する注意: 1 つのシーンで同時に使用する 3D コンテナは 3~4 個までにしてください。preserve-3d コンテナごとに GPU コンポジットレイヤーが生成されます。

CardFlip3D
const CardFlip3D: React.FC<{
  frontContent: React.ReactNode;
  backContent: React.ReactNode;
  flipDelay?: number;
  flipDuration?: number;
}> = ({ frontContent, backContent, flipDelay = 0.5, flipDuration = 1.2 }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const card = container.querySelector('.card-3d')!;
    tl.to(card, {
      rotateY: 180, duration: flipDuration, ease: 'power2.inOut',
    }, flipDelay);
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ perspective: 800 }}>
        <div className="card-3d" style={{
          width: 500, height: 320, position: 'relative', transformStyle: 'preserve-3d',
        }}>
          {/* Front face */}
          <div style={{
            position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
            background: '#1e293b', borderRadius: 16, display: 'flex',
            alignItems: 'center', justifyContent: 'center', padding: 32,
          }}>{frontContent}</div>
          {/* Back face (pre-rotated 180deg) */}
          <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>
  );
};
PerspectiveEntrance

2 つの要素が rotateY で左右の反対側から入り、中央に集まります。

const PerspectiveEntrance: React.FC<{
  leftContent: React.ReactNode;
  rightContent: React.ReactNode;
}> = ({ leftContent, rightContent }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const left = container.querySelector('.pe-left')!;
    const right = container.querySelector('.pe-right')!;

    tl.from(left, { x: -600, rotateY: 60, opacity: 0, duration: 0.8, ease: 'power3.out' })
      .from(right, { x: 600, rotateY: -60, opacity: 0, duration: 0.8, ease: 'power3.out' }, '-=0.5')
      .to(left, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3')
      .to(right, { rotateY: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 800 }}>
      <div ref={containerRef} style={{ display: 'flex', gap: 40, alignItems: 'center' }}>
        <div className="pe-left">{leftContent}</div>
        <div className="pe-right">{rightContent}</div>
      </div>
    </AbsoluteFill>
  );
};
RotateXTextSwap

退出するテキストは後ろに傾き、入ってくるテキストは前に倒れます。正しい端を支点にするため、transformOrigin を使用します。

const RotateXTextSwap: React.FC<{
  textOut: string;
  textIn: string;
  swapDelay?: number;
}> = ({ textOut, textIn, swapDelay = 1.0 }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const outEl = container.querySelector('.swap-out')!;
    const inEl = container.querySelector('.swap-in')!;

    // Out: tilt backward
    tl.to(outEl, {
      rotateX: 90, opacity: 0, duration: 0.5,
      transformOrigin: 'center bottom', ease: 'power2.in',
    }, swapDelay);

    // In: fall forward
    tl.fromTo(inEl,
      { rotateX: -90, opacity: 0, transformOrigin: 'center top' },
      { rotateX: 0, opacity: 1, duration: 0.6, ease: 'power2.out' },
      `>${-0.15}`
    );
  });

  return (
    <AbsoluteFill style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', perspective: 600 }}>
      <div ref={containerRef} style={{ position: 'relative', textAlign: 'center' }}>
        <h1 className="swap-out" style={{ fontSize: 80, fontWeight: 'bold', color: '#fff' }}>{textOut}</h1>
        <h1 className="swap-in" style={{
          fontSize: 80, fontWeight: 'bold', color: '#3b82f6',
          position: 'absolute', inset: 0,
        }}>{textIn}</h1>
      </div>
    </AbsoluteFill>
  );
};

4. インタラクションのシミュレーション

CursorClick

カーソルがターゲットまで移動してクリックする動作をシミュレートします。カーソルがスライドして入り、ターゲットが押し下がり、波紋が広がります。

const CursorClick: React.FC<{
  targetSelector: string;
  cursorDelay?: number;
  clickDelay?: number;
  children: React.ReactNode;
}> = ({ targetSelector, cursorDelay = 0.3, clickDelay = 0.8, children }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const target = container.querySelector(targetSelector)!;
    const cursor = container.querySelector('.sim-cursor')!;
    const ripple = container.querySelector('.sim-ripple')!;

    const rect = target.getBoundingClientRect();
    const containerRect = container.getBoundingClientRect();
    const targetX = rect.left - containerRect.left + rect.width / 2;
    const targetY = rect.top - containerRect.top + rect.height / 2;

    // Cursor travels to target
    tl.fromTo(cursor,
      { x: containerRect.width + 40, y: targetY - 20 },
      { x: targetX, y: targetY, duration: clickDelay, ease: 'power2.inOut' },
      cursorDelay
    );

    // Click: target depresses and releases
    tl.to(target, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
      .to(target, { scale: 1, duration: 0.15, ease: 'power2.out' });

    // Ripple expands (overlaps with click release)
    tl.fromTo(ripple,
      { x: targetX, y: targetY, scale: 0, opacity: 1 },
      { scale: 3, opacity: 0, duration: 0.6, ease: 'power2.out' },
      '<-0.1'
    );
  });

  return (
    <AbsoluteFill>
      <div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
        {children}
        {/* Cursor */}
        <svg className="sim-cursor" width="24" height="24" viewBox="0 0 24 24"
          style={{ position: 'absolute', top: 0, left: 0, zIndex: 100, pointerEvents: 'none' }}>
          <path d="M5 3l14 8-6 2-4 6z" fill="#fff" stroke="#000" strokeWidth="1.5" />
        </svg>
        {/* Ripple */}
        <div className="sim-ripple" style={{
          position: 'absolute', top: 0, left: 0, width: 40, height: 40,
          borderRadius: '50%', border: '2px solid rgba(59,130,246,0.6)',
          transform: 'translate(-50%, -50%) scale(0)', pointerEvents: 'none',
        }} />
      </div>
    </AbsoluteFill>
  );
};

Props: targetSelector は、コンテナ内で「クリック」する要素を指定する CSS セレクターです。カーソルは画面外の右側から入ります。


5. トランジション

クリップパスによるトランジション

パフォーマンスに関する注意: 複雑なクリップパスのアニメーション(特に polygon)は、Remotion のヘッドレス Chrome でのフレーム生成を遅くすることがあります。レンダリング時間が長い場合は、不透明度や変形を使った代替方法への置き換え、または circle/inset 形状への簡略化を検討してください。

const containerRef = useGSAPTimeline((tl, container) => {
  const scene = container.querySelector('.scene')!;

  // Circle reveal
  tl.fromTo(scene,
    { clipPath: 'circle(0% at 50% 50%)' },
    { clipPath: 'circle(75% at 50% 50%)', duration: 1, ease: 'power2.out' }
  );
});
トランジション開始時終了時
円形リビールcircle(0% at 50% 50%)circle(75% at 50% 50%)
左ワイプpolygon(0 0, 0 0, 0 100%, 0 100%)polygon(0 0, 100% 0, 100% 100%, 0 100%)
アイリスpolygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)
ブラインドinset(0 0 100% 0)inset(0 0 0% 0)
スライド/クロスフェード
// Slide transition
tl.to(outgoing, { x: '-100%', duration: 0.6, ease: 'power2.inOut' })
  .fromTo(incoming, { x: '100%' }, { x: '0%', duration: 0.6, ease: 'power2.inOut' }, 0);

// Crossfade
tl.to(outgoing, { opacity: 0, duration: 1 })
  .fromTo(incoming, { opacity: 0 }, { opacity: 1, duration: 1 }, 0);

6. テンプレート

ローワーサード
const LowerThird: React.FC<{ name: string; title: string; hold?: number }> = ({
  name, title, hold = 4,
}) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const bar = container.querySelector('.lt-bar')!;
    const nameEl = container.querySelector('.lt-name')!;
    const titleEl = container.querySelector('.lt-title')!;

    // In
    tl.fromTo(bar, { scaleX: 0, transformOrigin: 'left' }, { scaleX: 1, duration: 0.4, ease: 'power2.out' })
      .from(nameEl, { x: -30, opacity: 0, duration: 0.3 }, '-=0.1')
      .from(titleEl, { x: -20, opacity: 0, duration: 0.3 }, '-=0.1')
    // Hold
      .to({}, { duration: hold })
    // Out
      .to([bar, nameEl, titleEl], { x: -50, opacity: 0, duration: 0.3, stagger: 0.05, ease: 'power2.in' });
  });

  return (
    <AbsoluteFill>
      <div ref={containerRef} style={{ position: 'absolute', bottom: 80, left: 60 }}>
        <div className="lt-bar" style={{ background: '#3b82f6', padding: '12px 24px', borderRadius: 4 }}>
          <div className="lt-name" style={{ fontSize: 28, fontWeight: 'bold', color: '#fff' }}>{name}</div>
          <div className="lt-title" style={{ fontSize: 18, color: 'rgba(255,255,255,0.8)' }}>{title}</div>
        </div>
      </div>
    </AbsoluteFill>
  );
};
タイトルカード
const TitleCard: React.FC<{ mainTitle: string; subtitle?: string }> = ({ mainTitle, subtitle }) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const bgShape = container.querySelector('.bg-shape')!;
    const titleEl = container.querySelector('.main-title')!;
    const divider = container.querySelector('.divider')!;

    tl.from(bgShape, { scale: 0, rotation: -45, duration: 0.8, ease: 'back.out(1.7)' });

    const split = SplitText.create(titleEl, { type: 'chars', mask: 'chars' });
    tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' }, '-=0.3')
      .from('.subtitle', { y: 20, opacity: 0, duration: 0.6 }, '-=0.2')
      .from(divider, { scaleX: 0, duration: 0.4, ease: 'power2.out' }, '-=0.3');
  });

  return (
    <AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
        <div className="bg-shape" style={{ position: 'absolute', width: 200, height: 200, borderRadius: '50%', background: 'rgba(59,130,246,0.2)', top: '30%', left: '45%' }} />
        <h1 className="main-title" style={{ fontSize: 80, fontWeight: 'bold', position: 'relative' }}>{mainTitle}</h1>
        <div className="divider" style={{ width: 80, height: 3, background: '#3b82f6', margin: '20px auto' }} />
        {subtitle && <p className="subtitle" style={{ fontSize: 32, opacity: 0.8 }}>{subtitle}</p>}
      </div>
    </AbsoluteFill>
  );
};
ロゴリビール(DrawSVG)
const LogoReveal: React.FC<{ svgContent: React.ReactNode; text?: string }> = ({ svgContent, text }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const paths = container.querySelectorAll('.logo-path');
    tl.from(paths, { drawSVG: 0, duration: 1.5, stagger: 0.1, ease: 'power2.inOut' })
      .to(paths, { fill: '#fff', duration: 0.5 }, '-=0.3');
    if (text) {
      tl.from(container.querySelector('.logo-text')!, { opacity: 0, x: -20, duration: 0.5 }, '-=0.2');
    }
  });

  return (
    <AbsoluteFill style={{ background: '#000', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
        <svg viewBox="0 0 100 100" width={120} height={120}>{svgContent}</svg>
        {text && <span className="logo-text" style={{ fontSize: 48, color: '#fff', fontWeight: 'bold' }}>{text}</span>}
      </div>
    </AbsoluteFill>
  );
};
アニメーションカウンター

Remotion の interpolate() を使って、フレームごとに決定論的に計算します。GSAP は不要です。カウンターは純粋な数学的処理です。

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

const AnimatedCounter: React.FC<{
  endValue: number; prefix?: string; suffix?: string; durationInSeconds?: number;
}> = ({ endValue, prefix = '', suffix = '', durationInSeconds = 2 }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const value = interpolate(frame, [0, durationInSeconds * fps], [0, endValue], {
    extrapolateRight: 'clamp',
    easing: (t) => 1 - Math.pow(1 - t, 2), // power1.out equivalent
  });

  return (
    <div style={{ fontSize: 96, fontWeight: 'bold', fontVariantNumeric: 'tabular-nums' }}>
      {prefix}{Math.round(value).toLocaleString()}{suffix}
    </div>
  );
};
アウトロ(クロージングシーン)
const Outro: React.FC<{ headline: string; tagline?: string; logoSvg?: React.ReactNode }> = ({
  headline, tagline, logoSvg,
}) => {
  const containerRef = useGSAPWithFonts((tl, container) => {
    const headlineEl = container.querySelector('.outro-headline')!;
    const split = SplitText.create(headlineEl, { type: 'chars', mask: 'chars' });

    tl.from(split.chars, { y: '100%', duration: 0.5, stagger: 0.03, ease: 'power3.out' });
    if (tagline) {
      tl.from('.outro-tagline', { opacity: 0, y: 15, duration: 0.5, ease: 'power2.out' }, '-=0.2');
    }
    if (logoSvg) {
      const paths = container.querySelectorAll('.outro-logo path');
      tl.from(paths, { drawSVG: 0, duration: 1.2, stagger: 0.08, ease: 'power2.inOut' }, '-=0.3')
        .to(paths, { fill: '#fff', duration: 0.4 }, '-=0.2');
    }
  });

  return (
    <AbsoluteFill style={{ background: '#0f172a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <div ref={containerRef} style={{ textAlign: 'center', color: '#fff' }}>
        <h1 className="outro-headline" style={{ fontSize: 72, fontWeight: 'bold' }}>{headline}</h1>
        {tagline && <p className="outro-tagline" style={{ fontSize: 28, opacity: 0.7, marginTop: 16 }}>{tagline}</p>}
        {logoSvg && <div className="outro-logo" style={{ marginTop: 40 }}>{logoSvg}</div>}
      </div>
    </AbsoluteFill>
  );
};
SplitScreenComparison

2 つのパネルを横並びにし、時間差で表示します。中央バッジはオプションで、左パネルを暗くするエフェクトも備えています。

const SplitScreenComparison: React.FC<{
  leftPanel: React.ReactNode;
  rightPanel: React.ReactNode;
  leftLabel?: string;
  rightLabel?: string;
  centerElement?: React.ReactNode;
  dimLeft?: boolean;
  hold?: number;
}> = ({ leftPanel, rightPanel, leftLabel, rightLabel, centerElement, dimLeft = false, hold = 2 }) => {
  const containerRef = useGSAPTimeline((tl, container) => {
    const left = container.querySelector('.ssc-left')!;
    const right = container.querySelector('.ssc-right')!;
    const badge = container.querySelector('.ssc-badge');

    // Staggered entrance
    tl.from(left, { x: -80, opacity: 0, duration: 0.6, ease: 'power2.out' })
      .from(right, { x: 80, opacity: 0, duration: 0.6, ease: 'power2.out' }, '-=0.4');

    // Center badge pop
    if (badge) {
      tl.from(badge, { scale: 0, duration: 0.4, ease: 'back.out(2)' }, '-=0.2');
    }

    // Hold
    tl.to({}, { duration: hold });

    // Dim left, pop right (comparison effect)
    if (dimLeft) {
      tl.to(left, { opacity: 0.5, filter: 'blur(4px)', duration: 0.5, ease: 'power2.inOut' })
        .to(right, { scale: 1.02, duration: 0.5, ease: 'power2.out' }, '<');
    }
  });

  return (
    <AbsoluteFill style={{ display: 'flex' }}>
      <div ref={containerRef} style={{ display: 'flex', width: '100%', height: '100%' }}>
        <div className="ssc-left" style={{
          flex: 1, background: '#1e1e2e', display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', padding: 40, position: 'relative',
        }}>
          {leftLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{leftLabel}</div>}
          {leftPanel}
        </div>
        {centerElement && (
          <div className="ssc-badge" style={{
            position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%, -50%)',
            zIndex: 10, background: '#3b82f6', borderRadius: '50%', width: 60, height: 60,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontSize: 20, fontWeight: 'bold', color: '#fff',
          }}>{centerElement}</div>
        )}
        <div className="ssc-right" style={{
          flex: 1, background: 'rgba(255,255,255,0.06)', backdropFilter: 'blur(20px)',
          display: 'flex', flexDirection: 'column', alignItems: 'center',
          justifyContent: 'center', padding: 40,
        }}>
          {rightLabel && <div style={{ fontSize: 24, opacity: 0.6, marginBottom: 16, color: '#fff' }}>{rightLabel}</div>}
          {rightPanel}
        </div>
      </div>
    </AbsoluteFill>
  );
};

Props: 右パネルを「勝たせる」比較シーンでは、dimLeft: true を設定します。左パネルには暗い背景(#1e1e2e)を、右パネルにはグラスモーフィズム(backdropFilter: blur(20px))を使用します。


7. 登録済みエフェクト

流暢なタイムライン API を使うには、エフェクトを事前登録します。エントリーポイントで一度だけインポートしてください。

// lib/gsap-effects.ts
gsap.registerEffect({
  name: 'textReveal',
  effect: (targets, config) => {
    const split = SplitText.create(targets, { type: 'lines', mask: 'lines' });
    return gsap.from(split.lines, { y: '100%', duration: config.duration, stagger: config.stagger, ease: config.ease });
  },
  defaults: { duration: 0.6, stagger: 0.15, ease: 'power3.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'charCascade',
  effect: (targets, config) => {
    const split = SplitText.create(targets, { type: 'chars' });
    return gsap.from(split.chars, { y: 50, opacity: 0, rotationX: -90, duration: config.duration, stagger: config.stagger, ease: config.ease });
  },
  defaults: { duration: 0.5, stagger: 0.02, ease: 'back.out(1.7)' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'circleReveal',
  effect: (targets, config) => gsap.fromTo(targets,
    { clipPath: 'circle(0% at 50% 50%)' },
    { clipPath: 'circle(75% at 50% 50%)', duration: config.duration, ease: config.ease }),
  defaults: { duration: 1, ease: 'power2.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'wipeIn',
  effect: (targets, config) => gsap.fromTo(targets,
    { clipPath: 'inset(0 100% 0 0)' },
    { clipPath: 'inset(0 0% 0 0)', duration: config.duration, ease: config.ease }),
  defaults: { duration: 0.8, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'drawIn',
  effect: (targets, config) => gsap.from(targets, { drawSVG: 0, duration: config.duration, stagger: config.stagger, ease: config.ease }),
  defaults: { duration: 1.5, stagger: 0.1, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'flipCard',
  effect: (targets, config) => gsap.to(targets,
    { rotateY: 180, duration: config.duration, ease: config.ease }),
  defaults: { duration: 1.2, ease: 'power2.inOut' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'perspectiveIn',
  effect: (targets, config) => {
    const fromX = config.fromRight ? 600 : -600;
    const fromRotateY = config.fromRight ? -60 : 60;
    return gsap.from(targets, {
      x: fromX, rotateY: fromRotateY, opacity: 0,
      duration: config.duration, ease: config.ease,
    });
  },
  defaults: { duration: 0.8, ease: 'power3.out', fromRight: false },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'textHighlight',
  effect: (targets, config) => gsap.from(targets, {
    scaleX: 0, transformOrigin: 'left center',
    duration: config.duration, stagger: config.stagger, ease: config.ease,
  }),
  defaults: { duration: 0.3, stagger: 0.3, ease: 'power2.out' },
  extendTimeline: true,
});

gsap.registerEffect({
  name: 'cursorClick',
  effect: (targets, config) => {
    const tl = gsap.timeline();
    tl.to(targets, { scale: 0.95, duration: 0.1, ease: 'power2.in' })
      .to(targets, { scale: 1, duration: 0.15, ease: 'power2.out' });
    return tl;
  },
  defaults: {},
  extendTimeline: true,
});

// Simple property animations (fade, slide, scale) should use Remotion's
// interpolate() directly — GSAP registered effects are reserved for
// operations that Remotion cannot do natively (SplitText, DrawSVG, etc.).

使用方法:

const containerRef = useGSAPTimeline((tl, container) => {
  tl.textReveal('.title')
    .charCascade('.subtitle', {}, '-=0.3')
    .circleReveal('.scene-2', {}, '+=0.5')
    .flipCard('.card-3d')
    .perspectiveIn('.panel-left')
    .perspectiveIn('.panel-right', { fromRight: true }, '-=0.5')
    .textHighlight('.highlight-box')
    .cursorClick('.cta-button', {}, '+=0.3')
    .drawIn('.logo-path');
});

8. イージングのリファレンス

動きの印象GSAP イージング用途
なめらかな減速power2.out標準的な登場
強い減速power3.out / expo.out印象的な登場
緩やかな加速power2.in標準的な退場
両端でなめらかpower1.inOutシーントランジション
わずかなオーバーシュートback.out(1.7)注目を引く動き、バウンスイン
弾性のあるスプリングelastic.out(1, 0.5)ロゴ、遊び心のある動き
バウンスCustomBounce衝撃、着地
シェイク/振動CustomWiggle注目を引く動き、エラー
有機的/ぎざぎざした動きRoughEase緊張感、グリッチ
カスタムカーブCustomEase.create("id", "M0,0 C...")ブランド固有の動き
中間で減速slow(0.7, 0.7, false)映画のような速度変化

GSAP 専用のイージング(Remotion に相当するものはありません): CustomEase、RoughEase、SlowMo、CustomBounce、CustomWiggle、ExpoScaleEase。


9. react-animation スキルとの組み合わせ

const CombinedScene: React.FC = () => (
  <AbsoluteFill>
    {/* react-animation: visual atmosphere */}
    <Aurora colorStops={['#3A29FF', '#FF94B4']} />

    {/* gsap-animation: text + motion */}
    <GSAPTextReveal text="Beautiful Motion" />
    <GSAPLogoReveal svgContent={...} />

    {/* react-animation: film grain overlay */}
    <NoiseOverlay opacity={0.05} />
  </AbsoluteFill>
);
スキル最適な用途
react-animationビジュアル背景(Aurora、Silk、Particles)、シェーダーエフェクト、WebGL
gsap-animationテキストアニメーション、SVG モーション、タイムラインのオーケストレーション、トランジション、テンプレート

10. コンポジションの登録

export const RemotionRoot: React.FC = () => (
  <>
    <Composition id="TitleCard" component={TitleCard}
      durationInFrames={120} fps={30} width={1920} height={1080}
      defaultProps={{ mainTitle: 'HELLO WORLD', subtitle: 'A GSAP Motion Story' }} />
    <Composition id="LowerThird" component={LowerThird}
      durationInFrames={210} fps={30} width={1920} height={1080}
      defaultProps={{ name: 'Jane Smith', title: 'Creative Director' }} />
    <Composition id="LogoReveal" component={LogoReveal}
      durationInFrames={90} fps={30} width={1920} height={1080}
      defaultProps={{ svgContent: <circle className="logo-path" cx={50} cy={50} r={40} fill="none" stroke="#fff" strokeWidth={2} />, text: 'BRAND' }} />
    <Composition id="Outro" component={Outro}
      durationInFrames={120} fps={30} width={1920} height={1080}
      defaultProps={{ headline: 'THANK YOU', tagline: 'See you next time' }} />
    {/* Social media variants */}
    <Composition id="IGStory-TitleCard" component={TitleCard}
      durationInFrames={150} fps={30} width={1080} height={1920}
      defaultProps={{ mainTitle: 'SWIPE UP' }} />
  </>
);

11. レンダリング

# Default MP4
npx remotion render src/index.ts TitleCard --output out/title.mp4

# High quality
npx remotion render src/index.ts TitleCard --codec h264 --crf 15

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

# ProRes for editing
npx remotion render src/index.ts TitleCard --codec prores --prores-profile 4444

# With audio
# Use <Audio src={staticFile('bgm.mp3')} /> in composition

# Transparent background (alpha channel)
npx remotion render src/index.ts Overlay --codec prores --prores-profile 4444 --output out/overlay.mov
npx remotion render src/index.ts Overlay --codec vp9 --output out/overlay.webm
透過背景のフォーマット
フォーマットアルファ対応品質ファイルサイズ互換性
ProRes 4444 (.mov)はい可逆大Final Cut、Premiere、DaVinci
WebM VP9 (.webm)はい非可逆小Web、Chrome、After Effects
MP4 H.264 (.mp4)いいえ非可逆小幅広い環境で再生可能
GIF (.gif)1 ビットのみ低中Web、ソーシャルメディア

プロフェッショナルな合成には ProRes 4444 を使用します。Web のオーバーレイには WebM VP9 を使用します。MP4/H.264 はアルファチャンネルに対応していません。

コメント