1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カスタムHooksで実現する Reactアニメーション33選:軽量で再利用可能な実装テクニック

Posted at

はじめに

Reactでアニメーションを実装する際、カスタムHooksを活用することでロジックの再利用性を高め、保守性の良いコードを書くことができます。2025年現在、Framer Motion(現在はMotion)やReact Springなどの高機能なライブラリが主流となっていますが、基本的なアニメーション制御にはカスタムHooksが最適です。シンプルで理解しやすく、何より軽量です。

この記事では、実プロジェクトで実装した33種類のカスタムHooksを紹介します。すべて実際に動作するコードに基づいた解説です。

本記事で紹介するアニメーションは、以下の6つのカテゴリに分類されています:

  • Display & Transition - 表示・遷移アニメーション
  • Motion & Movement - モーション・動きのアニメーション
  • Interaction - インタラクティブアニメーション
  • UI Components - UIコンポーネントアニメーション
  • Text & Number - テキスト・数値アニメーション
  • Scroll & List - スクロール・リストアニメーション

ソースコードは下記にあります。
https://github.com/yunbow/react-app-showcase-animation

Display & Transition

Fade In/Out

レコーディング 2025-10-12 173523.gif

最も基本的なフェードイン・アウトアニメーションです。opacityのCSS transitionを使用したシンプルな実装ですが、fadeIn/fadeOut関数を提供することで柔軟な制御を実現しています。

このHooksでは、トリガーによる自動フェードイン機能と、手動でフェードイン・アウトを制御できる関数の両方を提供しています。また、遅延開始やアニメーション完了時のコールバックにも対応しており、複雑なアニメーションシーケンスの一部として組み込むことも可能です。

const [opacity, setOpacity] = useState(trigger ? 0 : 0);

useEffect(() => {
  if (!trigger) return;
  const timer = setTimeout(() => {
    setOpacity(1);
    setIsVisible(true);
  }, delay);
  return () => clearTimeout(timer);
}, [trigger, delay]);

const style = {
  opacity,
  transition: `opacity ${duration}ms ease-in-out`,
};

Blur Reveal

レコーディング 2025-10-12 173623.gif

ぼかし(blur)から徐々に鮮明になるアニメーションです。requestAnimationFrameを使用して滑らかにblur値を減少させます。

requestAnimationFrameを使用することで、ブラウザのリフレッシュレートに同期した滑らかなアニメーションを実現しています。CSS transitionではなく、フレームごとにblur値を計算・更新することで、より細かい制御が可能になっています。最大ブラー量やアニメーション時間は自由にカスタマイズできるため、コンテンツの性質に応じた最適な演出が可能です。

const animate = () => {
  const elapsed = Date.now() - startTime;
  const progress = Math.min(elapsed / duration, 1);
  const currentBlur = maxBlur * (1 - progress);

  setBlurAmount(currentBlur);

  if (progress < 1) {
    animationFrameRef.current = requestAnimationFrame(animate);
  }
};

const blurStyle = {
  filter: `blur(${blurAmount}px)`,
  transition: 'none',  // rAFで制御するためtransitionは無効化
};

Reveal from Lines

レコーディング 2025-10-12 173728.gif

線が伸びてテキストが現れる2段階アニメーションです。まず線が横に伸び、その後テキストがフェードインします。

2段階のアニメーションは単一のrequestAnimationFrameループで制御されており、進捗度(progress)を基準に線とテキストのアニメーションタイミングを計算しています。線が50%伸びた時点でテキストのフェードインが始まるように設計されており、自然な流れで2つのアニメーションが連続します。テキストには透明度だけでなく、Y軸方向の移動も加えることで、より動的な演出を実現しています。

const animate = () => {
  const progress = Math.min(elapsed / duration, 1);
  setLineProgress(progress);

  // 50%経過後にテキストをフェードイン
  if (progress > 0.5) {
    const textProgress = (progress - 0.5) * 2;
    setTextOpacity(textProgress);
  }
};

const lineStyle = {
  width: `${lineProgress * 100}%`,
};

const textStyle = {
  opacity: textOpacity,
  transform: `translateY(${(1 - textOpacity) * 20}px)`,
};

Color Interpolation

レコーディング 2025-10-12 173830.gif

ジェネリック型を使用した汎用的な値補間フックです。色補間のヘルパー関数を提供しています。

このフックの優れた点は、補間ロジックをカスタマイズ可能な関数として渡せることです。色補間の場合、16進数カラーコードをRGB値に分解し、R・G・B各チャンネルを個別に補間した後、再びRGB形式に合成しています。さらに、カスタムイージング関数をサポートすることで、線形補間だけでなく、easeIn、easeOut、バウンスなど、多様なアニメーションカーブを実現できます。この汎用的な設計により、1つのHooksで様々なアニメーションニーズに対応できます。

const useInterpolate = <T,>({
  from, to, duration, easing, interpolate, onComplete
}: UseInterpolateProps<T>) => {
  const animate = useCallback(() => {
    const update = () => {
      const rawProgress = Math.min(elapsed / duration, 1);
      const easedProgress = easing(rawProgress);
      const currentValue = interpolate(from, to, easedProgress);
      setValue(currentValue);
    };
    requestAnimationFrame(update);
  }, [from, to, duration, easing, interpolate]);
};

export const interpolateColor = (from: string, to: string, progress: number) => {
  const fromRgb = hexToRgb(from);
  const toRgb = hexToRgb(to);

  const r = Math.round(interpolateNumber(fromRgb.r, toRgb.r, progress));
  const g = Math.round(interpolateNumber(fromRgb.g, toRgb.g, progress));
  const b = Math.round(interpolateNumber(fromRgb.b, toRgb.b, progress));

  return `rgb(${r}, ${g}, ${b})`;
};

Transform Interpolation

レコーディング 2025-10-12 174535.gif

位置、回転、スケールを同時に補間するトランスフォーム専用のフックです。

線形補間(lerp: Linear Interpolation)関数を使用して、開始値から終了値への滑らかな遷移を計算しています。lerp関数は最もシンプルな補間アルゴリズムで、start + (end - start) * progressという式で2つの値の間を線形に補間します。このフックでは、x、y、rotation、scaleの4つの値に対してlerpを個別に適用し、カスタムイージング関数にも対応することで、より自然で洗練された動きを実現しています。

interface Transform {
  x: number;
  y: number;
  rotation: number;
  scale: number;
}

const lerp = (start: number, end: number, t: number): number => {
  return start + (end - start) * t;
};

const step = (currentTime: number) => {
  const easedProgress = easing(progress);

  setTransform({
    x: lerp(from.x, to.x, easedProgress),
    y: lerp(from.y, to.y, easedProgress),
    rotation: lerp(from.rotation, to.rotation, easedProgress),
    scale: lerp(from.scale, to.scale, easedProgress),
  });
};

Motion & Movement

Shake

レコーディング 2025-10-12 174712.gif

エラー通知やユーザーアクションの失敗時に使用するシェイクエフェクトです。時間経過とともに揺れが減衰します。

揺れの強度が時間経過とともに徐々に減衰していく実装がポイントです。intensity * (1 - progress)という式で、アニメーションの進行に応じて揺れ幅を小さくしています。ランダムな方向(x, y)への移動をMath.random()で生成し、周波数パラメータで揺れの間隔を調整できます。最終的には揺れが完全に止まり、要素が元の位置に戻ることで、自然な動きを実現しています。

const animate = () => {
  const progress = Math.min(elapsed / duration, 1);

  if (progress < 1) {
    // 減衰する揺れの強度
    const currentIntensity = intensity * (1 - progress);
    setOffset({
      x: (Math.random() - 0.5) * currentIntensity * 2,
      y: (Math.random() - 0.5) * currentIntensity * 2,
    });
    setTimeout(() => requestAnimationFrame(animate), frequency);
  } else {
    setOffset({ x: 0, y: 0 });
  }
};

Pulse

レコーディング 2025-10-12 174830.gif

周期的な拡大縮小アニメーションです。サイン波を使用して滑らかな脈動を実現します。

サイン波(sine wave)を使用することで、等速ではない自然な拡大縮小を実現しているのが特徴です。Math.sin()関数は-1から1の範囲で滑らかに変化するため、これを0から1の範囲に正規化((sineProgress + 1) / 2)してスケール値に変換しています。ループ再生をサポートしており、周期を繰り返すことで継続的な脈動効果を生み出します。スケールの最小値と最大値をカスタマイズできるため、控えめな脈動から大胆な拡大まで、用途に応じた調整が可能です。

const animate = () => {
  const loopProgress = (elapsed % duration) / duration;

  // サイン波を使用して滑らかな拡大縮小
  const sineProgress = Math.sin(loopProgress * Math.PI * 2);
  const currentScale = minScale + ((maxScale - minScale) * (sineProgress + 1)) / 2;

  setScale(currentScale);

  if (loop || elapsed < duration) {
    animationRef.current = requestAnimationFrame(animate);
  }
};

Bounce

レコーディング 2025-10-12 175026.gif

物理法則を模倣したバウンス効果です。減衰するバウンスを数学的に実装しています。

サイン波と減衰係数を組み合わせた数学的なアプローチが特徴です。Math.sin(progress * Math.PI * bounces)で周期的な上下運動を生成し、1 - progressという減衰係数を掛けることで、時間経過とともにバウンスの高さが徐々に小さくなっていきます。バウンス回数(bounces)をカスタマイズできるため、1回だけの大きな跳ね返りから、複数回の細かい跳ね返りまで調整可能です。垂直・水平方向の選択もでき、様々なシチュエーションに対応できます。

const animate = () => {
  const progress = Math.min(elapsed / duration, 1);

  // バウンスの計算
  const bounceProgress = progress * Math.PI * bounces;
  const dampening = 1 - progress;  // 減衰係数
  const currentOffset = Math.sin(bounceProgress) * intensity * dampening;

  setOffset(currentOffset);
};

const style = {
  transform: direction === 'vertical'
    ? `translateY(${offset}px)`
    : `translateX(${offset}px)`,
};

Wave Effect

レコーディング 2025-10-12 175257.gif

クリック位置から広がる波紋エフェクトです。複数の波を時間差で生成します。

複数の波を時間差で生成することで、より豊かで重層的な視覚効果を実現しています。各波は独自のIDを持ち、進捗度(progress)に応じて半径が拡大し、透明度が減少していきます。radius = progress * maxRadiusopacity = 1 - progressという式により、波が外側に広がるにつれて薄くなり、最終的に消えていく自然な動きを表現しています。時間差での生成により、連続する波紋のような効果を生み出し、単一の波よりもダイナミックで目を引くアニメーションを実現しています。

const createWave = useCallback((x: number, y: number) => {
  for (let i = 0; i < count; i++) {
    setTimeout(() => {
      setWaves((prev) => [...prev, { id: waveId + i, x, y, progress: 0 }]);

      const animate = () => {
        setWaves((prev) =>
          prev.map((wave) =>
            wave.id === waveId + i ? { ...wave, progress } : wave
          )
        );

        if (progress < 1) {
          requestAnimationFrame(animate);
        } else {
          setWaves((prev) => prev.filter((wave) => wave.id !== waveId + i));
        }
      };

      requestAnimationFrame(animate);
    }, i * 200);  // 時間差で波を生成
  }
}, [duration, count]);

const renderWaves = () => {
  return waves.map((wave) => {
    const radius = wave.progress * maxRadius;
    const opacity = 1 - wave.progress;
    return <div style={{ /* 波紋のスタイル */ }} />;
  });
};

Neon Pulse

レコーディング 2025-10-12 175416.gif

ネオンサインのような光るエフェクトです。box-shadowを使用してグロー効果を実現します。

box-shadowを多層に重ねることで、リアルなグロー(発光)効果を実現しているのが特徴です。3つの異なるシャドウ層(通常のシャドウ2つとinsetシャドウ1つ)を組み合わせ、外側への光の広がりと内側からの発光を同時に表現しています。サイン波による周期的な強度変化により、ネオンサインが明滅するような自然な効果を生み出します。色と強度をカスタマイズできるため、控えめな輝きから強烈な発光まで、デザインに応じた調整が可能です。

const animate = () => {
  const cycle = (elapsed % duration) / duration;
  const pulse = Math.sin(cycle * Math.PI * 2) * 0.5 + 0.5;

  setGlowIntensity(pulse * intensity);
};

const pulseStyle = {
  boxShadow: `
    0 0 ${glowIntensity}px ${color},
    0 0 ${glowIntensity * 2}px ${color},
    inset 0 0 ${glowIntensity / 2}px ${color}
  `,
  transition: 'none',
};

Interaction

Hover

レコーディング 2025-10-12 175528.gif

ホバー状態を管理し、遅延付きのホバーアニメーションを提供します。

入場(enter)と退場(leave)の遅延時間を個別に設定できることが特徴です。例えば、ホバー時には即座に反応させつつ、マウスが離れた時には少し遅延させることで、誤操作を防ぎながらも反応性の高いUIを実現できます。タイムアウトの適切なクリーンアップにより、素早いマウス移動時にも正確に動作し、メモリリークを防ぎます。シンプルながら、実用的なホバー体験を提供する重要なフックです。

const handleMouseEnter = () => {
  if (timeoutRef.current) {
    clearTimeout(timeoutRef.current);
  }

  timeoutRef.current = setTimeout(() => {
    setIsHovered(true);
  }, enterDelay);
};

const handleMouseLeave = () => {
  if (timeoutRef.current) {
    clearTimeout(timeoutRef.current);
  }

  timeoutRef.current = setTimeout(() => {
    setIsHovered(false);
  }, leaveDelay);
};

Draggable

レコーディング 2025-10-12 175723.gif

要素をドラッグ可能にし、境界制限もサポートします。

マウスイベントベースの正確なドラッグ制御が特徴です。ドラッグ開始位置と要素の初期位置を記録し、マウスの移動量(delta)を計算して要素を追従させます。境界制限(bounds)をサポートしており、要素が特定の領域外に移動しないよう制約できます。カーソルの動的変更(grab/grabbing)により、ドラッグ可能な状態とドラッグ中の状態を視覚的に区別し、ドラッグ終了時のコールバックで後続処理を実行できます。

const constrainPosition = useCallback((pos: Position): Position => {
  if (!bounds) return pos;

  return {
    x: Math.max(bounds.left ?? -Infinity, Math.min(bounds.right ?? Infinity, pos.x)),
    y: Math.max(bounds.top ?? -Infinity, Math.min(bounds.bottom ?? Infinity, pos.y)),
  };
}, [bounds]);

const handleMouseMove = useCallback((e: MouseEvent) => {
  if (!isDragging) return;

  const deltaX = e.clientX - dragStartPos.current.x;
  const deltaY = e.clientY - dragStartPos.current.y;

  const newPosition = {
    x: elementStartPos.current.x + deltaX,
    y: elementStartPos.current.y + deltaY,
  };

  setPosition(constrainPosition(newPosition));
}, [isDragging, constrainPosition]);

Ripple Effect

レコーディング 2025-10-12 175828.gif

Material Designのリップルエフェクトです。クリック位置から円が広がります。

クリック位置の正確な検出が実装の核心です。getBoundingClientRect()で要素の位置とサイズを取得し、クリック座標から波紋の中心点を計算します。波紋のサイズは要素の幅と高さの大きい方に合わせることで、どこをクリックしても要素全体をカバーできるようになっています。複数の波紋を同時に管理できるため、連続クリックにも対応し、CSS animationと組み合わせることで滑らかな広がりと消失を実現しています。

const createRipple = useCallback((event: React.MouseEvent<HTMLElement>) => {
  const element = event.currentTarget;
  const rect = element.getBoundingClientRect();
  const size = Math.max(rect.width, rect.height);
  const x = event.clientX - rect.left - size / 2;
  const y = event.clientY - rect.top - size / 2;

  const newRipple: Ripple = { x, y, size, id: Date.now() };

  setRipples((prev) => [...prev, newRipple]);

  setTimeout(() => {
    setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
  }, duration);
}, [duration]);

Magnetic Hover

レコーディング 2025-10-12 175924.gif

カーソルが近づくと要素が引き寄せられるような効果です。

距離ベースの引き寄せ力計算が特徴です。カーソルと要素の中心点との距離をMath.sqrt()で算出し、影響範囲(range)内にあるかを判定します。距離に応じた力(force = 1 - distance / range)を計算し、近いほど強く、遠いほど弱い引き寄せ効果を実現しています。スムージング処理により、即座に移動するのではなく徐々に目標位置に近づくことで、より自然で滑らかな動きを生み出します。影響範囲と強度をカスタマイズできるため、控えめな効果から劇的な効果まで調整可能です。

const handleMouseMove = (e: MouseEvent) => {
  const rect = element.getBoundingClientRect();
  const centerX = rect.left + rect.width / 2;
  const centerY = rect.top + rect.height / 2;

  const deltaX = e.clientX - centerX;
  const deltaY = e.clientY - centerY;
  const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

  if (distance < range) {
    const force = 1 - distance / range;  // 距離に応じた力
    targetPosition.current = {
      x: deltaX * strength * force,
      y: deltaY * strength * force,
    };
  } else {
    targetPosition.current = { x: 0, y: 0 };
  }
};

// スムージング処理
const animate = () => {
  setPosition((prev) => ({
    x: prev.x + (targetPosition.current.x - prev.x) * smoothing,
    y: prev.y + (targetPosition.current.y - prev.y) * smoothing,
  }));
  animationRef.current = requestAnimationFrame(animate);
};

Follow Cursor

レコーディング 2025-10-12 180101.gif

カスタムカーソルがマウスを追従します。複数のスタイルをサポート。

スムージング(smoothing)処理がこのエフェクトの核心です。現在位置から目標位置への移動をrequestAnimationFrameで毎フレーム計算し、距離の一定割合(smoothing値)だけ近づくことで、滑らかな遅延追従を実現しています。pointerEvents: 'none'を設定することで、カスタムカーソル自体がマウスイベントを妨げないようにし、ユーザーが通常通りクリックやホバーなどの操作を行えるようにしています。

const animate = () => {
  setPosition((prev) => ({
    x: prev.x + (targetPosition.x - prev.x) * smoothing,
    y: prev.y + (targetPosition.y - prev.y) * smoothing,
  }));
  animationFrameRef.current = requestAnimationFrame(animate);
};

const getCursorStyle = (): CSSProperties => {
  const baseStyle: CSSProperties = {
    position: 'fixed',
    transform: `translate(${position.x}px, ${position.y}px)`,
    pointerEvents: 'none',
    zIndex: 9999,
  };

  return { ...baseStyle};
};

Magnetic Drag

レコーディング 2025-10-12 180300.gif

ドラッグ時に弾性的な動きをする、磁石のようなドラッグエフェクトです。

弾性係数(elasticity)パラメータが動きの特性を決定します。この値は0から1の範囲で、現在位置と目標位置の差分にこの係数を掛けることで、毎フレームの移動量を計算します。値が小さいほど「重い」感じの動きになり、大きいほど「軽快」な動きになります。ドラッグ終了時には目標位置を原点(0, 0)に設定することで、要素が自動的に元の位置に戻るバネのような挙動を実現しています。

const animate = () => {
  setPosition((prev) => {
    const dx = targetPosition.x - prev.x;
    const dy = targetPosition.y - prev.y;

    // 十分に近づいたら停止
    if (Math.abs(dx) < 0.1 && Math.abs(dy) < 0.1 && !isDragging) {
      return targetPosition;
    }

    // 弾性的な動き
    return {
      x: prev.x + dx * elasticity,
      y: prev.y + dy * elasticity,
    };
  });

  animationFrameRef.current = requestAnimationFrame(animate);
};

const handleMouseUp = () => {
  setIsDragging(false);
  setTargetPosition({ x: 0, y: 0 });  // 原点に戻る
};

Reveal on Hover

レコーディング 2025-10-12 180517.gif

ホバー時にマスクがスライドしてコンテンツが現れるエフェクトです。

clip-pathプロパティのinset()関数を使用した効率的な実装が特徴です。inset(0 100% 0 0)は要素の右側100%を切り取る(非表示にする)ことを意味し、inset(0 0 0 0)で完全に表示されます。この値をCSS transitionで滑らかに変化させることで、マスクがスライドするような視覚効果を実現しています。clip-pathはGPUアクセラレーションが効くため、パフォーマンスも優れています。

const maskStyle: React.CSSProperties = {
  clipPath: isHovered ? 'inset(0 0 0 0)' : 'inset(0 100% 0 0)',
  transition: 'clip-path 0.6s ease',
};

UI Components

Modal

レコーディング 2025-10-12 180723.gif

開閉アニメーション付きのモーダル制御フックです。

2段階の状態管理(isVisibleisAnimating)が実装の鍵です。isVisibleはDOM上の存在を制御し、isAnimatingは実際のアニメーション状態を管理します。開く時は、まず要素をDOMにマウント(isVisible: true)し、次のフレームでアニメーションを開始(isAnimating: true)します。閉じる時は、先にアニメーションを終了させ、アニメーション完了後にDOMから削除することで、滑らかな退場アニメーションを実現しています。この2段階方式により、CSS transitionが正しく発火し、美しいアニメーションが可能になります。

useEffect(() => {
  if (isOpen) {
    setIsVisible(true);
    requestAnimationFrame(() => {
      setIsAnimating(true);  // 次のフレームで開始
    });
  } else {
    setIsAnimating(false);
    const timer = setTimeout(() => {
      setIsVisible(false);  // アニメーション終了後に非表示
    }, duration);
    return () => clearTimeout(timer);
  }
}, [isOpen, duration]);

const backdropStyle = {
  opacity: isAnimating ? 1 : 0,
  pointerEvents: isVisible ? 'auto' as const : 'none' as const,
};

const modalStyle = {
  transform: isAnimating ? 'scale(1) translateY(0)' : 'scale(0.9) translateY(-20px)',
  opacity: isAnimating ? 1 : 0,
};

Slide Menu

レコーディング 2025-10-12 180850.gif

4方向からスライドインするメニューです。

translateXtranslateYを方向に応じて使い分けることで、4方向のスライドを実現しています。左からのスライドはtranslateX(-100%)からtranslateX(0)へ、右からはtranslateX(100%)からtranslateX(0)へと変化します。同様に上下方向もtranslateYで制御します。cubic-bezier イージング関数(0.4, 0, 0.2, 1)を使用することで、加速と減速が自然なスライドモーションを実現し、マテリアルデザインのような洗練された動きになっています。

const getTransform = () => {
  const transforms = {
    left: isAnimating ? 'translateX(0)' : 'translateX(-100%)',
    right: isAnimating ? 'translateX(0)' : 'translateX(100%)',
    top: isAnimating ? 'translateY(0)' : 'translateY(-100%)',
    bottom: isAnimating ? 'translateY(0)' : 'translateY(100%)',
  };
  return transforms[direction];
};

const menuStyle = {
  transform: getTransform(),
  transition: `transform ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};

Carousel

レコーディング 2025-10-12 181123.gif

自動再生対応のカルーセルコンポーネントです。

インデックスベースの位置計算がカルーセルの核心です。各スライドの位置は(index - currentIndex) * 100%という式で計算され、現在表示中のスライドを基準に相対的な位置を決定します。アニメーション中の連続クリック防止機能(isAnimatingフラグ)により、スライドの切り替え途中に次のスライドへ進むことを防ぎ、アニメーションが完全に終わるまで次の操作を待機します。これにより、視覚的な混乱やバグを防ぎ、滑らかで予測可能なユーザー体験を実現しています。

const goToNext = useCallback(() => {
  if (isAnimating) return;

  const nextIndex = currentIndex + 1;
  if (nextIndex < itemCount) {
    goToSlide(nextIndex);
  } else if (loop) {
    goToSlide(0);  // ループ再生
  }
}, [currentIndex, itemCount, loop, isAnimating, goToSlide]);

useEffect(() => {
  if (!autoPlay) return;

  const interval = setInterval(() => {
    goToNext();
  }, autoPlayInterval);

  return () => clearInterval(interval);
}, [autoPlay, autoPlayInterval, goToNext]);

const getSlideStyle = (index: number) => {
  const offset = index - currentIndex;
  const translateX = offset * 100;

  return {
    transform: `translateX(${translateX}%)`,
    transition: isAnimating ? `transform ${transition}ms cubic-bezier(0.4, 0, 0.2, 1)` : 'none',
    position: 'absolute' as const,
  };
};

Accordion

レコーディング 2025-10-12 181242.gif

高さアニメーション付きのアコーディオンです。

scrollHeightによる動的な高さ取得が実装の鍵です。コンテンツの高さは中身によって変わるため、refを使って実際のDOM要素にアクセスし、scrollHeightプロパティから本来の高さを取得します。開く時はこの高さを設定し、閉じる時は0に設定することで、CSSのheightトランジションが発火し、滑らかなアニメーションが実現されます。overflow: hiddenにより、アニメーション中にコンテンツがはみ出さないよう制御されています。

const [height, setHeight] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (contentRef.current) {
    const contentHeight = contentRef.current.scrollHeight;
    setHeight(isOpen ? contentHeight : 0);  // 実際の高さを取得
  }
}, [isOpen]);

const containerStyle = {
  height: `${height}px`,
  overflow: 'hidden',
  transition: `height ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};

Flip Card

レコーディング 2025-10-12 181349.gif

3D回転でカードが裏返るエフェクトです。

CSS 3Dトランスフォームの活用が実装の核心です。perspectiveプロパティで3D空間の遠近感を設定し、transformStyle: 'preserve-3d'で子要素の3D変形を維持します。backfaceVisibility: 'hidden'により、カードの裏面(現在見えていない面)を非表示にすることで、回転中に裏面が透けて見える不自然な表示を防ぎます。裏面は事前に180度回転させておき、カード全体を回転させることで表裏が切り替わる仕組みです。

const containerStyle = {
  perspective: '1000px',  // 3D空間の設定
};

const cardStyle = {
  transformStyle: 'preserve-3d' as const,
  transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
  transition: `transform ${duration}ms cubic-bezier(0.4, 0, 0.2, 1)`,
};

const faceStyle = {
  backfaceVisibility: 'hidden' as const,  // 裏面を隠す
};

const backStyle = {
  ...faceStyle,
  transform: 'rotateY(180deg)',  // 裏面を事前に回転
};

Loading Spinner

レコーディング 2025-10-12 181440.gif

4種類のスピナータイプを提供するフックです。

setIntervalを使用した60FPSの回転制御が基本的な実装方法です。circular タイプでは、回転角度を毎フレーム更新することで連続的な回転を実現します。dots タイプではサイン波を使用して各ドットの透明度やスケールを時間差で変化させ、波打つような動きを表現します。pulse タイプもサイン波でスケールを変化させることで脈動効果を、bars タイプでは複数のバーの高さを異なる位相でアニメーションさせることで、リズミカルな動きを生み出します。

const [rotation, setRotation] = useState(0);

useEffect(() => {
  const interval = setInterval(() => {
    setRotation((prev) => (prev + 360 * speed / 60) % 360);
  }, 1000 / 60);  // 60FPS

  return () => clearInterval(interval);
}, [speed]);

const getSpinnerStyle = (): CSSProperties => {
  switch (type) {
    case 'circular':
      return {
        border: `${size / 10}px solid ${color}20`,
        borderTopColor: color,
        transform: `rotate(${rotation}deg)`,
      };
    case 'dots':
      // 8個のドットを円形配置
    case 'pulse':
      // サイン波でスケール変化
    case 'bars':
      // 複数のバーの高さをアニメーション
  }
};

Progress Bar

レコーディング 2025-10-12 181555.gif

イージング付きのプログレスバーアニメーションです。

4種類のイージング関数(linear、easeIn、easeOut、easeInOut)を提供しています。linear は一定速度、easeIn は徐々に加速、easeOut は徐々に減速、easeInOut は加速してから減速する動きを実現します。requestAnimationFrameで進捗値を滑らかに更新し、開始値から目標値への遷移を任意の時間で制御できます。進捗が100%に達するとonCompleteコールバックが呼ばれ、後続処理をトリガーできます。

const easingFunctions = {
  linear: (t: number) => t,
  easeIn: (t: number) => t * t,
  easeOut: (t: number) => t * (2 - t),
  easeInOut: (t: number) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
};

const animate = () => {
  const rawProgress = Math.min(elapsed / duration, 1);
  const easedProgress = easingFunctions[easing](rawProgress);
  const currentProgress = startProgress + progressDelta * easedProgress;

  setProgress(currentProgress);

  if (rawProgress < 1) {
    animationFrameRef.current = requestAnimationFrame(animate);
  } else {
    setIsAnimating(false);
    if (targetProgress >= 100) {
      onComplete?.();
    }
  }
};

Text & Number

Count Up/Down

レコーディング 2025-10-12 181701.gif

数値が増減するアニメーションです。

setIntervalを使用して一定間隔で数値を増減させる実装です。ステップ数(step)と間隔(interval)をカスタマイズできるため、素早いカウントからゆっくりとした変化まで調整可能です。小数点以下の桁数(decimals)を指定でき、整数だけでなく小数のカウントにも対応します。.toFixed()で丸め誤差を防ぎ、正確な数値表示を保証しています。

const startCountUp = useCallback(() => {
  if (isAnimating) return;

  setIsAnimating(true);
  intervalRef.current = window.setInterval(() => {
    setCount((prev) => Number((prev + step).toFixed(decimals)));
  }, interval);
}, [isAnimating, step, interval, decimals]);

const startCountDown = useCallback(() => {
  if (isAnimating) return;

  setIsAnimating(true);
  intervalRef.current = window.setInterval(() => {
    setCount((prev) => Number((prev - step).toFixed(decimals)));
  }, interval);
}, [isAnimating, step, interval, decimals]);

Typewriter

レコーディング 2025-10-12 181815.gif

一文字ずつ表示されるタイピングアニメーションです。

setTimeoutを使用したシンプルな実装が特徴です。現在のインデックスを管理し、毎回テキストの先頭から現在位置までをスライスして表示します。速度(speed)パラメータで文字の表示間隔を調整でき、素早いタイピングからゆっくりとした表示まで対応します。ループ再生時は、テキストが完全に表示された後、一定時間待機してからリセットし、再度タイピングを開始します。

useEffect(() => {
  if (!isTyping) return;

  if (currentIndex < text.length) {
    const timer = setTimeout(() => {
      setDisplayText(text.slice(0, currentIndex + 1));
      setCurrentIndex(currentIndex + 1);
    }, speed);

    return () => clearTimeout(timer);
  } else if (loop) {
    const resetTimer = setTimeout(() => {
      setDisplayText('');
      setCurrentIndex(0);
    }, 1000);

    return () => clearTimeout(resetTimer);
  }
}, [currentIndex, text, speed, isTyping, loop]);

Number Scramble

レコーディング 2025-10-12 182011.gif

数字がランダムに変化しながら目標値に収束します。

段階的な数字の確定が実装の核心です。進捗度(progress)に応じて、左から順に文字を確定させていきます。確定済みの部分は目標値の該当文字を表示し、未確定部分はランダムな文字を生成して表示します。50msごとにランダム文字を更新することで、高速で数字が切り替わるような視覚効果を生み出します。最終的に進捗が100%に達すると、すべての文字が確定し、目標値が完全に表示されます。

const scramble = () => {
  intervalRef.current = window.setInterval(() => {
    const progress = Math.min(elapsed / duration, 1);

    if (progress >= 1) {
      setDisplayValue(target);
      return;
    }

    const revealedLength = Math.floor(target.length * progress);
    const scrambledPart = target
      .slice(revealedLength)
      .split('')
      .map(() => characters[Math.floor(Math.random() * characters.length)])
      .join('');

    setDisplayValue(target.slice(0, revealedLength) + scrambledPart);
  }, 50);
};

Text Shuffle

レコーディング 2025-10-12 182112.gif

テキストがランダムな文字からゆっくり正しい文字に変わるエフェクトです。

文字単位での段階的確定が実装の特徴です。各文字を個別に処理し、確定済みの文字はそのまま表示、未確定の文字はランダムな文字に置き換えます。スペースは特別扱いされ、常に保持されることで、単語の区切りが維持されます。豊富な文字セット(英数字、記号など)からランダムに選択することで、多様な変化を表現し、50msごとの高速更新により滑らかなシャッフル効果を実現しています。

const shuffle = () => {
  intervalRef.current = window.setInterval(() => {
    const progress = Math.min(elapsed / duration, 1);

    const revealedLength = Math.floor(text.length * progress);
    const result = text.split('').map((char, index) => {
      if (index < revealedLength) {
        return char;  // 確定した文字
      }
      if (char === ' ') {
        return ' ';  // スペースは維持
      }
      return characters[Math.floor(Math.random() * characters.length)];
    }).join('');

    setDisplayText(result);
  }, 50);
};

Text Reveal

レコーディング 2025-10-12 182213.gif

マスクがスライドしてテキストが現れるエフェクトです。

clip-pathによる効率的なマスク制御が実装の核心です。inset(0 ${100 - maskPosition}% 0 0)という式で、右側からのマスク量を進捗度に応じて減少させます。requestAnimationFrameを使用することで、ブラウザのリフレッシュレートに同期した滑らかなアニメーションを実現します。遅延開始機能により、複数のテキスト要素を時間差で順番に表示するスタガードアニメーションも可能です。

const animate = () => {
  const progress = Math.min(elapsed / duration, 1);
  setMaskPosition(progress * 100);

  if (progress < 1) {
    animationFrameRef.current = requestAnimationFrame(animate);
  }
};

const maskStyle: React.CSSProperties = {
  clipPath: `inset(0 ${100 - maskPosition}% 0 0)`,
  transition: 'none',
};

Scroll & List

Scroll

レコーディング 2025-10-12 182433.gif

IntersectionObserverを使用したスクロール連動アニメーションです。

IntersectionObserver APIによる効率的な可視性検出が実装の核心です。要素が画面内に表示されているかを自動で監視し、無駄なスクロールイベントリスナーを削減します。passive: trueオプションによりスクロールパフォーマンスを最適化し、滑らかなスクロール体験を保証します。threshold(可視性の閾値)やrootMargin(マージン)をカスタマイズでき、「50%見えたら発火」「画面下端より100px手前で発火」など、細かい制御が可能です。

useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      setIsVisible(entry.isIntersecting);
    },
    { threshold, rootMargin }
  );

  const currentElement = elementRef.current;
  if (currentElement) {
    observer.observe(currentElement);
  }

  window.addEventListener('scroll', handleScroll, { passive: true });

  return () => {
    if (currentElement) {
      observer.unobserve(currentElement);
    }
    window.removeEventListener('scroll', handleScroll);
  };
}, [threshold, rootMargin, handleScroll]);

const getTransform = useCallback((parallaxSpeed = 0.5) => {
  return `translateY(${scrollY * parallaxSpeed}px)`;
}, [scrollY]);

Staggered List

レコーディング 2025-10-12 182636.gif

リストアイテムが順番にアニメーションで現れます。

CSS の transition-delay を活用したシンプルで効率的な実装が特徴です。各アイテムのインデックスに遅延時間(staggerDelay)を掛けることで、順次表示のタイミングを制御します。JavaScriptのループではなくCSSトランジションで実現するため、GPUアクセラレーションが効き、パフォーマンスに優れています。総アニメーション時間を計算してアニメーション完了を検出し、後続処理をトリガーできます。

const getItemStyle = (index: number): CSSProperties => {
  if (!hasStarted) {
    return {
      opacity: 0,
      transform: 'translateY(20px)',
    };
  }

  return {
    opacity: 1,
    transform: 'translateY(0)',
    transition: `all ${animationDuration}ms ease-out`,
    transitionDelay: `${index * staggerDelay}ms`,  // インデックスに応じた遅延
  };
};

useEffect(() => {
  if (!hasStarted) return;

  const totalDuration = staggerDelay * itemCount + animationDuration;
  const timer = setTimeout(() => {
    setIsAnimating(false);
  }, totalDuration);

  return () => clearTimeout(timer);
}, [hasStarted, itemCount, staggerDelay, animationDuration]);

Scroll Snap

レコーディング 2025-10-12 182751.gif

ネイティブCSS Scroll Snap APIを使用した、JavaScriptコード不要の実装が最大の特徴です。scroll-snap-typeでスナップの方向と必須性を指定し、scroll-snap-alignで各アイテムのスナップ位置を設定します。ブラウザネイティブの慣性スクロールがそのまま機能するため、滑らかで自然なスクロール感覚を保ちつつ、JavaScriptライブラリに依存しない軽量で高パフォーマンスな実装を実現しています。

主要な実装ポイント:

const containerStyle: React.CSSProperties = {
  scrollSnapType: 'y mandatory',
  overflowY: 'scroll',
  height: '500px',
};

const itemStyle: React.CSSProperties = {
  scrollSnapAlign: 'start',
  scrollSnapStop: 'always',
};

まとめ

これらのHooksは、モダンなWebアプリケーションのUX向上に役立ちます。ライブラリに依存せず、軽量で拡張性の高い実装を実現できます。

ぜひプロジェクトに取り入れて、より魅力的なUIを作ってください!


参考資料

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?