はじめに
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
最も基本的なフェードイン・アウトアニメーションです。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
ぼかし(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
線が伸びてテキストが現れる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
ジェネリック型を使用した汎用的な値補間フックです。色補間のヘルパー関数を提供しています。
このフックの優れた点は、補間ロジックをカスタマイズ可能な関数として渡せることです。色補間の場合、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
位置、回転、スケールを同時に補間するトランスフォーム専用のフックです。
線形補間(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
エラー通知やユーザーアクションの失敗時に使用するシェイクエフェクトです。時間経過とともに揺れが減衰します。
揺れの強度が時間経過とともに徐々に減衰していく実装がポイントです。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
周期的な拡大縮小アニメーションです。サイン波を使用して滑らかな脈動を実現します。
サイン波(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
物理法則を模倣したバウンス効果です。減衰するバウンスを数学的に実装しています。
サイン波と減衰係数を組み合わせた数学的なアプローチが特徴です。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
クリック位置から広がる波紋エフェクトです。複数の波を時間差で生成します。
複数の波を時間差で生成することで、より豊かで重層的な視覚効果を実現しています。各波は独自のIDを持ち、進捗度(progress)に応じて半径が拡大し、透明度が減少していきます。radius = progress * maxRadius
とopacity = 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
ネオンサインのような光るエフェクトです。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
ホバー状態を管理し、遅延付きのホバーアニメーションを提供します。
入場(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
要素をドラッグ可能にし、境界制限もサポートします。
マウスイベントベースの正確なドラッグ制御が特徴です。ドラッグ開始位置と要素の初期位置を記録し、マウスの移動量(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
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
カーソルが近づくと要素が引き寄せられるような効果です。
距離ベースの引き寄せ力計算が特徴です。カーソルと要素の中心点との距離を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
カスタムカーソルがマウスを追従します。複数のスタイルをサポート。
スムージング(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
ドラッグ時に弾性的な動きをする、磁石のようなドラッグエフェクトです。
弾性係数(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
ホバー時にマスクがスライドしてコンテンツが現れるエフェクトです。
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
開閉アニメーション付きのモーダル制御フックです。
2段階の状態管理(isVisible
とisAnimating
)が実装の鍵です。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
4方向からスライドインするメニューです。
translateX
とtranslateY
を方向に応じて使い分けることで、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
自動再生対応のカルーセルコンポーネントです。
インデックスベースの位置計算がカルーセルの核心です。各スライドの位置は(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
高さアニメーション付きのアコーディオンです。
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
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
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
イージング付きのプログレスバーアニメーションです。
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
数値が増減するアニメーションです。
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
一文字ずつ表示されるタイピングアニメーションです。
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
数字がランダムに変化しながら目標値に収束します。
段階的な数字の確定が実装の核心です。進捗度(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
テキストがランダムな文字からゆっくり正しい文字に変わるエフェクトです。
文字単位での段階的確定が実装の特徴です。各文字を個別に処理し、確定済みの文字はそのまま表示、未確定の文字はランダムな文字に置き換えます。スペースは特別扱いされ、常に保持されることで、単語の区切りが維持されます。豊富な文字セット(英数字、記号など)からランダムに選択することで、多様な変化を表現し、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
マスクがスライドしてテキストが現れるエフェクトです。
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
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
リストアイテムが順番にアニメーションで現れます。
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
ネイティブ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を作ってください!