CSS だけじゃ物足りない!React アプリを動かす JS アニメーション 10 選

CSS を超えた表現力を求めているなら、JavaScript アニメーションこそが次のステップです。React アプリケーションにおいて、CSS だけでは実現できない動的で高度なアニメーション表現を 10 個厳選してご紹介します。
API データのリアルタイム表示からパーティクルエフェクト、物理演算を活用したドラッグ操作まで、ユーザーの心を掴む魅力的なインタラクションを実装する実践的な手法をお伝えしていきますね。
背景
CSS アニメーションの限界と JavaScript の可能性
CSS アニメーションは確かに美しく、パフォーマンスも優秀です。しかし、動的なデータ変化への対応や複雑な条件分岐、ユーザーの操作に応じたリアルタイムな反応となると、どうしても限界が見えてきます。
特に以下のような場面では、JavaScript の柔軟性が必要不可欠になります。
# | 制約 | CSS の限界 | JavaScript の解決策 |
---|---|---|---|
1 | データ連動 | 静的な値のみ | API レスポンスと連動 |
2 | 複雑な条件判定 | 簡単な擬似クラスのみ | if 文や三項演算子による制御 |
3 | 非線形アニメーション | ease 関数の組み合わせのみ | 数学関数による自由な軌道 |
4 | インタラクション制御 | hover、focus 程度 | マウス座標やタッチジェスチャ |
5 | タイムライン制御 | animation-delay の組み合わせ | 時間軸の自由な制御 |
モダン React 開発における JavaScript アニメーションの位置づけ
2025 年現在、React の状態管理機能と JavaScript アニメーションを組み合わせることで、これまでにない表現力を獲得できるようになりました。
フックシステムの活用により、アニメーションの状態管理も宣言的に記述でき、従来の命令的な DOM 操作とは一線を画した開発体験を実現できます。
課題
CSS だけでは実現困難な表現とその限界
実際の開発現場では、以下のような要求に対して CSS だけでは対応しきれない場面が頻繁に発生します。
リアルタイムデータ表示の課題
css/* ❌ CSS では不可能:動的な数値変化のアニメーション */
.counter::after {
content: 'Loading...'; /* 固定値しか表示できない */
animation: fadeIn 1s ease-in-out;
}
複雑な物理演算の制約
css/* ❌ CSS では不可能:重力や摩擦を考慮した動き */
.ball {
animation: bounce 2s ease-in-out infinite;
/* 単純な up-down しか表現できない */
}
よく遭遇するエラーパターン
開発中によく発生するエラーとその原因をご紹介します。
Animation Not Working エラー
vbnetWarning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
このエラーは、コンポーネントがアンマウントされた後もアニメーションが実行され続けることで発生します。
Performance Warning
csharp[Violation] 'requestAnimationFrame' handler took 23ms
Warning: Maximum update depth exceeded.
フレームレートが低下している際に発生する警告で、重いアニメーション処理が原因となります。
TypeScript との型安全性の課題
JavaScript アニメーションを TypeScript で実装する際の型定義の複雑さも課題の一つです。
typescript// ❌ 型エラーが発生しやすいパターン
const animationRef = useRef<HTMLElement>(null);
useEffect(() => {
// Property 'animate' does not exist on type 'HTMLElement'
animationRef.current?.animate(
[
/* ... */
],
{ duration: 1000 }
);
}, []);
このような課題を解決するために、JavaScript ならではのアニメーション手法を活用した解決策をご紹介していきます。
解決策
JavaScript アニメーション 10 選
1. リアルタイム数値カウンター(API 連携データ表示)
API から取得したライブデータを滑らかにアニメーションしながら表示する手法です。
typescriptimport { useState, useEffect, useRef } from 'react';
interface CounterProps {
targetValue: number;
duration?: number;
formatter?: (value: number) => string;
}
function AnimatedCounter({
targetValue,
duration = 2000,
formatter = (value) => value.toLocaleString(),
}: CounterProps) {
const [currentValue, setCurrentValue] = useState(0);
const animationRef = useRef<number>();
const startTimeRef = useRef<number>();
useEffect(() => {
// 前回のアニメーションをキャンセル
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
startTimeRef.current = performance.now();
const startValue = currentValue;
const difference = targetValue - startValue;
const animate = (currentTime: number) => {
const elapsed =
currentTime - (startTimeRef.current || 0);
const progress = Math.min(elapsed / duration, 1);
// イージング関数(ease-out)
const easedProgress = 1 - Math.pow(1 - progress, 3);
const newValue =
startValue + difference * easedProgress;
setCurrentValue(newValue);
if (progress < 1) {
animationRef.current =
requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
// クリーンアップ
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [targetValue, duration]);
return (
<div className='animated-counter'>
<span className='counter-value'>
{formatter(currentValue)}
</span>
</div>
);
}
2. パーティクル爆発エフェクト(Canvas 活用)
成功時のフィードバックやゲーミフィケーション要素として活用できるパーティクル効果です。
typescriptimport { useRef, useEffect, useCallback } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
color: string;
size: number;
}
interface ParticleExplosionProps {
trigger: boolean;
onComplete?: () => void;
}
function ParticleExplosion({
trigger,
onComplete,
}: ParticleExplosionProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number>();
const createParticles = useCallback(
(centerX: number, centerY: number) => {
const particles: Particle[] = [];
const colors = [
'#ff6b6b',
'#4ecdc4',
'#45b7d1',
'#feca57',
'#ff9ff3',
];
for (let i = 0; i < 50; i++) {
const angle = (Math.PI * 2 * i) / 50;
const velocity = 2 + Math.random() * 4;
particles.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * velocity,
vy: Math.sin(angle) * velocity,
life: 60,
maxLife: 60,
color:
colors[
Math.floor(Math.random() * colors.length)
],
size: 2 + Math.random() * 3,
});
}
return particles;
},
[]
);
const animate = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
particlesRef.current = particlesRef.current.filter(
(particle) => {
// 物理演算
particle.x += particle.vx;
particle.y += particle.vy;
particle.vy += 0.1; // 重力
particle.vx *= 0.99; // 空気抵抗
particle.life--;
// 描画
const alpha = particle.life / particle.maxLife;
ctx.globalAlpha = alpha;
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(
particle.x,
particle.y,
particle.size,
0,
Math.PI * 2
);
ctx.fill();
return particle.life > 0;
}
);
if (particlesRef.current.length > 0) {
animationRef.current = requestAnimationFrame(animate);
} else {
onComplete?.();
}
}, [onComplete]);
useEffect(() => {
if (trigger) {
const canvas = canvasRef.current;
if (!canvas) return;
// パーティクル生成
particlesRef.current = createParticles(
canvas.width / 2,
canvas.height / 2
);
// アニメーション開始
animationRef.current = requestAnimationFrame(animate);
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [trigger, createParticles, animate]);
return (
<canvas
ref={canvasRef}
width={400}
height={300}
className='particle-canvas'
style={{
position: 'absolute',
pointerEvents: 'none',
zIndex: 1000,
}}
/>
);
}
3. インフィニットスクロール連動アニメーション
スクロール位置に応じて要素が段階的にアニメーションする実装です。
typescriptimport {
useState,
useEffect,
useRef,
useCallback,
} from 'react';
interface ScrollRevealProps {
children: React.ReactNode;
className?: string;
threshold?: number;
rootMargin?: string;
}
function ScrollReveal({
children,
className = '',
threshold = 0.1,
rootMargin = '0px',
}: ScrollRevealProps) {
const [isVisible, setIsVisible] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry.isIntersecting) {
setIsVisible(true);
// 一度表示されたら監視を停止(オプション)
observer.disconnect();
}
},
[]
);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(
handleIntersection,
{
threshold,
rootMargin,
}
);
observer.observe(element);
return () => observer.disconnect();
}, [handleIntersection, threshold, rootMargin]);
return (
<div
ref={elementRef}
className={`scroll-reveal ${className} ${
isVisible ? 'visible' : ''
}`}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible
? 'translateY(0)'
: 'translateY(50px)',
transition:
'opacity 0.6s ease-out, transform 0.6s ease-out',
}}
>
{children}
</div>
);
}
// 使用例:無限スクロールリスト
function InfiniteScrollList() {
const [items, setItems] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const loadMoreItems = useCallback(async () => {
setLoading(true);
try {
// API呼び出しをシミュレート
await new Promise((resolve) =>
setTimeout(resolve, 1000)
);
const newItems = Array.from(
{ length: 10 },
(_, i) => `Item ${items.length + i + 1}`
);
setItems((prev) => [...prev, ...newItems]);
} catch (error) {
console.error('Failed to load items:', error);
} finally {
setLoading(false);
}
}, [items.length]);
return (
<div className='infinite-scroll-container'>
{items.map((item, index) => (
<ScrollReveal key={index} className='list-item'>
<div className='item-content'>{item}</div>
</ScrollReveal>
))}
{!loading && (
<ScrollReveal threshold={1}>
<button
onClick={loadMoreItems}
className='load-more-button'
>
Load More
</button>
</ScrollReveal>
)}
{loading && (
<div className='loading-spinner'>Loading...</div>
)}
</div>
);
}
4. ドラッグ&ドロップ with 物理演算
物理的な慣性と反発を考慮したリアルなドラッグ操作を実装します。
typescriptimport {
useState,
useRef,
useEffect,
useCallback,
} from 'react';
interface Position {
x: number;
y: number;
}
interface Velocity {
x: number;
y: number;
}
interface DraggablePhysicsProps {
children: React.ReactNode;
onPositionChange?: (position: Position) => void;
}
function DraggablePhysics({
children,
onPositionChange,
}: DraggablePhysicsProps) {
const [position, setPosition] = useState<Position>({
x: 0,
y: 0,
});
const [isDragging, setIsDragging] = useState(false);
const velocityRef = useRef<Velocity>({ x: 0, y: 0 });
const lastPositionRef = useRef<Position>({ x: 0, y: 0 });
const animationRef = useRef<number>();
const elementRef = useRef<HTMLDivElement>(null);
// 物理演算のパラメータ
const friction = 0.85;
const bounceStrength = 0.6;
const updatePhysics = useCallback(() => {
if (isDragging) return;
const element = elementRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const parentRect =
element.parentElement?.getBoundingClientRect();
if (!parentRect) return;
setPosition((prev) => {
let newX = prev.x + velocityRef.current.x;
let newY = prev.y + velocityRef.current.y;
// 境界判定と反発
const maxX = parentRect.width - rect.width;
const maxY = parentRect.height - rect.height;
if (newX < 0 || newX > maxX) {
velocityRef.current.x *= -bounceStrength;
newX = Math.max(0, Math.min(maxX, newX));
}
if (newY < 0 || newY > maxY) {
velocityRef.current.y *= -bounceStrength;
newY = Math.max(0, Math.min(maxY, newY));
}
// 摩擦の適用
velocityRef.current.x *= friction;
velocityRef.current.y *= friction;
// 速度が十分小さくなったら停止
if (
Math.abs(velocityRef.current.x) < 0.1 &&
Math.abs(velocityRef.current.y) < 0.1
) {
velocityRef.current.x = 0;
velocityRef.current.y = 0;
return prev;
}
const newPosition = { x: newX, y: newY };
onPositionChange?.(newPosition);
return newPosition;
});
if (
velocityRef.current.x !== 0 ||
velocityRef.current.y !== 0
) {
animationRef.current =
requestAnimationFrame(updatePhysics);
}
}, [
isDragging,
onPositionChange,
friction,
bounceStrength,
]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsDragging(true);
lastPositionRef.current = {
x: e.clientX,
y: e.clientY,
};
// 物理演算を停止
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
velocityRef.current = { x: 0, y: 0 };
},
[]
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - lastPositionRef.current.x;
const deltaY = e.clientY - lastPositionRef.current.y;
// 速度を記録(慣性計算用)
velocityRef.current.x = deltaX * 0.1;
velocityRef.current.y = deltaY * 0.1;
setPosition((prev) => {
const newPosition = {
x: prev.x + deltaX,
y: prev.y + deltaY,
};
onPositionChange?.(newPosition);
return newPosition;
});
lastPositionRef.current = {
x: e.clientX,
y: e.clientY,
};
},
[isDragging, onPositionChange]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
// 慣性による物理演算を開始
animationRef.current =
requestAnimationFrame(updatePhysics);
}, [updatePhysics]);
useEffect(() => {
if (isDragging) {
document.addEventListener(
'mousemove',
handleMouseMove
);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener(
'mousemove',
handleMouseMove
);
document.removeEventListener(
'mouseup',
handleMouseUp
);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return (
<div
ref={elementRef}
style={{
position: 'absolute',
left: position.x,
top: position.y,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
transition: isDragging
? 'none'
: 'transform 0.1s ease-out',
}}
onMouseDown={handleMouseDown}
>
{children}
</div>
);
}
5. 状態変化に連動するモーフィング
React の状態変化に応じて要素の形状が滑らかに変化するアニメーションです。
typescriptimport { useState, useEffect, useMemo } from 'react';
interface MorphingButtonProps {
states: {
default: { text: string; color: string; width: number };
loading: { text: string; color: string; width: number };
success: { text: string; color: string; width: number };
error: { text: string; color: string; width: number };
};
currentState: keyof MorphingButtonProps['states'];
onClick?: () => void;
}
function MorphingButton({
states,
currentState,
onClick,
}: MorphingButtonProps) {
const [displayText, setDisplayText] = useState(
states[currentState].text
);
const [animatingText, setAnimatingText] = useState(false);
const currentConfig = states[currentState];
// テキスト変更のアニメーション
useEffect(() => {
if (displayText !== currentConfig.text) {
setAnimatingText(true);
// フェードアウト → テキスト変更 → フェードイン
const timer = setTimeout(() => {
setDisplayText(currentConfig.text);
setAnimatingText(false);
}, 150);
return () => clearTimeout(timer);
}
}, [currentConfig.text, displayText]);
const buttonStyle = useMemo(
() => ({
width: currentConfig.width,
backgroundColor: currentConfig.color,
color: 'white',
border: 'none',
borderRadius: '25px',
padding: '12px 24px',
fontSize: '16px',
fontWeight: 'bold',
cursor:
currentState === 'loading'
? 'not-allowed'
: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative' as const,
overflow: 'hidden' as const,
opacity: animatingText ? 0.7 : 1,
}),
[currentConfig, currentState, animatingText]
);
return (
<button
style={buttonStyle}
onClick={
currentState !== 'loading' ? onClick : undefined
}
disabled={currentState === 'loading'}
>
{currentState === 'loading' && (
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
width: '20px',
height: '20px',
border: '2px solid transparent',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
}}
/>
)}
<span
style={{
opacity: currentState === 'loading' ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
}}
>
{displayText}
</span>
</button>
);
}
// 使用例
function MorphingButtonDemo() {
const [buttonState, setButtonState] = useState<
'default' | 'loading' | 'success' | 'error'
>('default');
const handleClick = async () => {
setButtonState('loading');
try {
// API呼び出しをシミュレート
await new Promise((resolve, reject) => {
setTimeout(() => {
Math.random() > 0.5
? resolve(true)
: reject(new Error('Failed'));
}, 2000);
});
setButtonState('success');
setTimeout(() => setButtonState('default'), 2000);
} catch (error) {
setButtonState('error');
setTimeout(() => setButtonState('default'), 2000);
}
};
return (
<div style={{ padding: '20px' }}>
<MorphingButton
states={{
default: {
text: 'Submit',
color: '#3b82f6',
width: 120,
},
loading: {
text: 'Processing...',
color: '#6b7280',
width: 150,
},
success: {
text: 'Success!',
color: '#10b981',
width: 130,
},
error: {
text: 'Try Again',
color: '#ef4444',
width: 140,
},
}}
currentState={buttonState}
onClick={handleClick}
/>
</div>
);
}
6. マウス追従インタラクション
マウスカーソルの動きに反応して、要素が自然に追従するインタラクションです。
typescriptimport {
useState,
useRef,
useEffect,
useCallback,
} from 'react';
interface MousePosition {
x: number;
y: number;
}
interface MagneticElementProps {
children: React.ReactNode;
magnetism?: number;
distance?: number;
easing?: number;
}
function MagneticElement({
children,
magnetism = 0.3,
distance = 100,
easing = 0.15,
}: MagneticElementProps) {
const [mousePosition, setMousePosition] =
useState<MousePosition>({ x: 0, y: 0 });
const [elementPosition, setElementPosition] =
useState<MousePosition>({ x: 0, y: 0 });
const elementRef = useRef<HTMLDivElement>(null);
const animationRef = useRef<number>();
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const element = elementRef.current;
if (!element) return;
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 distanceToMouse = Math.sqrt(
deltaX * deltaX + deltaY * deltaY
);
if (distanceToMouse < distance) {
// 磁力の強さを距離に応じて調整
const force =
(distance - distanceToMouse) / distance;
setMousePosition({
x: deltaX * magnetism * force,
y: deltaY * magnetism * force,
});
} else {
setMousePosition({ x: 0, y: 0 });
}
},
[magnetism, distance]
);
const animateElement = useCallback(() => {
setElementPosition((prev) => ({
x: prev.x + (mousePosition.x - prev.x) * easing,
y: prev.y + (mousePosition.y - prev.y) * easing,
}));
animationRef.current =
requestAnimationFrame(animateElement);
}, [mousePosition, easing]);
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove);
animationRef.current =
requestAnimationFrame(animateElement);
return () => {
document.removeEventListener(
'mousemove',
handleMouseMove
);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [handleMouseMove, animateElement]);
return (
<div
ref={elementRef}
style={{
transform: `translate(${elementPosition.x}px, ${elementPosition.y}px)`,
transition: 'none',
}}
>
{children}
</div>
);
}
// カーソル追従エフェクト
function CursorFollower() {
const [cursorPosition, setCursorPosition] =
useState<MousePosition>({ x: 0, y: 0 });
const [trails, setTrails] = useState<
Array<MousePosition & { id: number }>
>([]);
useEffect(() => {
let trailId = 0;
const handleMouseMove = (e: MouseEvent) => {
const newPosition = { x: e.clientX, y: e.clientY };
setCursorPosition(newPosition);
// トレイル効果
setTrails((prev) => [
...prev.slice(-10), // 最新10個のトレイルを保持
{ ...newPosition, id: trailId++ },
]);
};
document.addEventListener('mousemove', handleMouseMove);
return () =>
document.removeEventListener(
'mousemove',
handleMouseMove
);
}, []);
return (
<>
{trails.map((trail, index) => (
<div
key={trail.id}
style={{
position: 'fixed',
left: trail.x,
top: trail.y,
width: '10px',
height: '10px',
backgroundColor: '#3b82f6',
borderRadius: '50%',
opacity: ((index + 1) / trails.length) * 0.5,
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 9999,
transition: 'opacity 0.2s ease-out',
}}
/>
))}
</>
);
}
7. タイムライン制御アニメーション
複数の要素を時間軸で制御する高度なアニメーションシステムです。
typescriptimport {
useState,
useRef,
useEffect,
useCallback,
} from 'react';
interface AnimationKeyframe {
time: number;
properties: Record<string, any>;
easing?: string;
}
interface TimelineItem {
id: string;
element: React.RefObject<HTMLElement>;
keyframes: AnimationKeyframe[];
}
class AnimationTimeline {
private items: TimelineItem[] = [];
private startTime: number = 0;
private isPlaying: boolean = false;
private animationFrame: number = 0;
private duration: number = 0;
addItem(item: TimelineItem) {
this.items.push(item);
// 全体の duration を計算
const maxTime = Math.max(
...item.keyframes.map((k) => k.time)
);
this.duration = Math.max(this.duration, maxTime);
}
play() {
if (this.isPlaying) return;
this.isPlaying = true;
this.startTime = performance.now();
this.animate();
}
pause() {
this.isPlaying = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
}
}
private animate = () => {
if (!this.isPlaying) return;
const currentTime = performance.now() - this.startTime;
const progress = Math.min(
currentTime / 1000,
this.duration
); // 秒単位
this.items.forEach((item) => {
this.updateElement(item, progress);
});
if (progress < this.duration) {
this.animationFrame = requestAnimationFrame(
this.animate
);
} else {
this.isPlaying = false;
}
};
private updateElement(
item: TimelineItem,
currentTime: number
) {
const element = item.element.current;
if (!element) return;
// 現在時刻に該当するキーフレームを見つける
const keyframes = item.keyframes.sort(
(a, b) => a.time - b.time
);
let fromKeyframe = keyframes[0];
let toKeyframe = keyframes[keyframes.length - 1];
for (let i = 0; i < keyframes.length - 1; i++) {
if (
currentTime >= keyframes[i].time &&
currentTime <= keyframes[i + 1].time
) {
fromKeyframe = keyframes[i];
toKeyframe = keyframes[i + 1];
break;
}
}
// 補間計算
const duration = toKeyframe.time - fromKeyframe.time;
const elapsed = currentTime - fromKeyframe.time;
const progress =
duration > 0 ? Math.min(elapsed / duration, 1) : 1;
// プロパティの補間
const interpolatedProperties: Record<string, any> = {};
Object.keys(toKeyframe.properties).forEach((prop) => {
const fromValue = fromKeyframe.properties[prop] || 0;
const toValue = toKeyframe.properties[prop];
if (
typeof fromValue === 'number' &&
typeof toValue === 'number'
) {
interpolatedProperties[prop] =
fromValue + (toValue - fromValue) * progress;
} else {
interpolatedProperties[prop] =
progress >= 1 ? toValue : fromValue;
}
});
// スタイル適用
this.applyStyles(element, interpolatedProperties);
}
private applyStyles(
element: HTMLElement,
properties: Record<string, any>
) {
Object.entries(properties).forEach(([prop, value]) => {
switch (prop) {
case 'x':
case 'y':
const x = properties.x || 0;
const y = properties.y || 0;
element.style.transform = `translate(${x}px, ${y}px)`;
break;
case 'scale':
element.style.transform = `scale(${value})`;
break;
case 'opacity':
element.style.opacity = value.toString();
break;
case 'backgroundColor':
element.style.backgroundColor = value;
break;
default:
(element.style as any)[prop] = value;
}
});
}
}
function TimelineDemo() {
const timeline = useRef(new AnimationTimeline());
const box1Ref = useRef<HTMLDivElement>(null);
const box2Ref = useRef<HTMLDivElement>(null);
const box3Ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// タイムライン設定
timeline.current.addItem({
id: 'box1',
element: box1Ref,
keyframes: [
{ time: 0, properties: { x: 0, opacity: 1 } },
{ time: 1, properties: { x: 200, opacity: 0.5 } },
{ time: 2, properties: { x: 0, opacity: 1 } },
],
});
timeline.current.addItem({
id: 'box2',
element: box2Ref,
keyframes: [
{
time: 0.5,
properties: {
scale: 1,
backgroundColor: '#3b82f6',
},
},
{
time: 1.5,
properties: {
scale: 1.5,
backgroundColor: '#ef4444',
},
},
{
time: 2.5,
properties: {
scale: 1,
backgroundColor: '#10b981',
},
},
],
});
timeline.current.addItem({
id: 'box3',
element: box3Ref,
keyframes: [
{ time: 1, properties: { y: 0, opacity: 0 } },
{ time: 2, properties: { y: -100, opacity: 1 } },
{ time: 3, properties: { y: 0, opacity: 0.5 } },
],
});
}, []);
return (
<div
style={{
padding: '50px',
position: 'relative',
height: '300px',
}}
>
<button onClick={() => timeline.current.play()}>
Play Timeline
</button>
<button onClick={() => timeline.current.pause()}>
Pause Timeline
</button>
<div
ref={box1Ref}
style={{
position: 'absolute',
top: '100px',
left: '50px',
width: '50px',
height: '50px',
backgroundColor: '#3b82f6',
borderRadius: '8px',
}}
/>
<div
ref={box2Ref}
style={{
position: 'absolute',
top: '100px',
left: '150px',
width: '50px',
height: '50px',
backgroundColor: '#3b82f6',
borderRadius: '50%',
}}
/>
<div
ref={box3Ref}
style={{
position: 'absolute',
top: '100px',
left: '250px',
width: '50px',
height: '50px',
backgroundColor: '#10b981',
borderRadius: '8px',
}}
/>
</div>
);
}
8. データ可視化チャートアニメーション
データの変化を視覚的に美しく表現するチャートアニメーションです。
typescriptimport { useState, useEffect, useRef } from 'react';
interface ChartData {
label: string;
value: number;
color: string;
}
interface AnimatedBarChartProps {
data: ChartData[];
width?: number;
height?: number;
animationDuration?: number;
}
function AnimatedBarChart({
data,
width = 400,
height = 300,
animationDuration = 1500,
}: AnimatedBarChartProps) {
const [animatedData, setAnimatedData] = useState<
ChartData[]
>([]);
const animationRef = useRef<number>();
const startTimeRef = useRef<number>();
useEffect(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
startTimeRef.current = performance.now();
const maxValue = Math.max(...data.map((d) => d.value));
const animate = (currentTime: number) => {
const elapsed =
currentTime - (startTimeRef.current || 0);
const progress = Math.min(
elapsed / animationDuration,
1
);
// イージング関数
const easedProgress = 1 - Math.pow(1 - progress, 3);
const newData = data.map((item) => ({
...item,
value: item.value * easedProgress,
}));
setAnimatedData(newData);
if (progress < 1) {
animationRef.current =
requestAnimationFrame(animate);
}
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [data, animationDuration]);
const maxValue = Math.max(...data.map((d) => d.value));
const barWidth = (width - 100) / data.length - 10;
return (
<div style={{ position: 'relative', width, height }}>
<svg width={width} height={height}>
{animatedData.map((item, index) => {
const barHeight =
(item.value / maxValue) * (height - 60);
const x = 50 + index * (barWidth + 10);
const y = height - barHeight - 30;
return (
<g key={item.label}>
<rect
x={x}
y={y}
width={barWidth}
height={barHeight}
fill={item.color}
rx={4}
style={{
filter:
'drop-shadow(0 2px 4px rgba(0,0,0,0.1))',
}}
/>
<text
x={x + barWidth / 2}
y={height - 10}
textAnchor='middle'
fontSize='12'
fill='#666'
>
{item.label}
</text>
<text
x={x + barWidth / 2}
y={y - 5}
textAnchor='middle'
fontSize='10'
fill='#333'
fontWeight='bold'
>
{Math.round(item.value)}
</text>
</g>
);
})}
</svg>
</div>
);
}
// 使用例:リアルタイムデータチャート
function RealTimeChart() {
const [chartData, setChartData] = useState<ChartData[]>([
{ label: 'Jan', value: 0, color: '#3b82f6' },
{ label: 'Feb', value: 0, color: '#10b981' },
{ label: 'Mar', value: 0, color: '#f59e0b' },
{ label: 'Apr', value: 0, color: '#ef4444' },
]);
const updateData = () => {
setChartData((prev) =>
prev.map((item) => ({
...item,
value: Math.floor(Math.random() * 100) + 10,
}))
);
};
return (
<div style={{ padding: '20px' }}>
<button
onClick={updateData}
style={{ marginBottom: '20px' }}
>
Update Data
</button>
<AnimatedBarChart data={chartData} />
</div>
);
}
9. ジェスチャー認識アニメーション
タッチジェスチャーやマウス操作パターンを認識してアニメーションを発火させます。
typescriptimport {
useState,
useRef,
useCallback,
useEffect,
} from 'react';
interface GesturePoint {
x: number;
y: number;
timestamp: number;
}
interface SwipeGestureProps {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number;
children: React.ReactNode;
}
function SwipeGestureDetector({
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
threshold = 50,
children,
}: SwipeGestureProps) {
const [touchStart, setTouchStart] =
useState<GesturePoint | null>(null);
const [currentTransform, setCurrentTransform] = useState({
x: 0,
y: 0,
});
const [isAnimating, setIsAnimating] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
setTouchStart({
x: touch.clientX,
y: touch.clientY,
timestamp: Date.now(),
});
},
[]
);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!touchStart || isAnimating) return;
const touch = e.touches[0];
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
// リアルタイムで要素を移動
setCurrentTransform({
x: deltaX * 0.5,
y: deltaY * 0.5,
});
e.preventDefault();
},
[touchStart, isAnimating]
);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
if (!touchStart) return;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
const deltaTime = Date.now() - touchStart.timestamp;
// 速度を考慮した判定
const velocityX = Math.abs(deltaX) / deltaTime;
const velocityY = Math.abs(deltaY) / deltaTime;
if (Math.abs(deltaX) > threshold && velocityX > 0.5) {
setIsAnimating(true);
if (deltaX > 0) {
onSwipeRight?.();
// 右スワイプアニメーション
setCurrentTransform({ x: 300, y: 0 });
} else {
onSwipeLeft?.();
// 左スワイプアニメーション
setCurrentTransform({ x: -300, y: 0 });
}
// アニメーション終了後にリセット
setTimeout(() => {
setCurrentTransform({ x: 0, y: 0 });
setIsAnimating(false);
}, 300);
} else if (
Math.abs(deltaY) > threshold &&
velocityY > 0.5
) {
setIsAnimating(true);
if (deltaY > 0) {
onSwipeDown?.();
setCurrentTransform({ x: 0, y: 200 });
} else {
onSwipeUp?.();
setCurrentTransform({ x: 0, y: -200 });
}
setTimeout(() => {
setCurrentTransform({ x: 0, y: 0 });
setIsAnimating(false);
}, 300);
} else {
// スワイプが不十分な場合は元に戻す
setCurrentTransform({ x: 0, y: 0 });
}
setTouchStart(null);
},
[
touchStart,
threshold,
onSwipeLeft,
onSwipeRight,
onSwipeUp,
onSwipeDown,
]
);
return (
<div
ref={elementRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
transform: `translate(${currentTransform.x}px, ${currentTransform.y}px)`,
transition: isAnimating
? 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
: 'none',
touchAction: 'none',
userSelect: 'none',
}}
>
{children}
</div>
);
}
// 長押しジェスチャー検出
function LongPressGesture({
onLongPress,
duration = 800,
children,
}: {
onLongPress: () => void;
duration?: number;
children: React.ReactNode;
}) {
const [isPressed, setIsPressed] = useState(false);
const [progress, setProgress] = useState(0);
const timerRef = useRef<number>();
const progressRef = useRef<number>();
const startPress = useCallback(() => {
setIsPressed(true);
setProgress(0);
const startTime = Date.now();
const updateProgress = () => {
const elapsed = Date.now() - startTime;
const newProgress = Math.min(elapsed / duration, 1);
setProgress(newProgress);
if (newProgress < 1) {
progressRef.current =
requestAnimationFrame(updateProgress);
} else {
onLongPress();
setIsPressed(false);
setProgress(0);
}
};
progressRef.current =
requestAnimationFrame(updateProgress);
}, [duration, onLongPress]);
const cancelPress = useCallback(() => {
setIsPressed(false);
setProgress(0);
if (progressRef.current) {
cancelAnimationFrame(progressRef.current);
}
}, []);
return (
<div
onMouseDown={startPress}
onMouseUp={cancelPress}
onMouseLeave={cancelPress}
onTouchStart={startPress}
onTouchEnd={cancelPress}
style={{
position: 'relative',
cursor: 'pointer',
transform: isPressed ? 'scale(0.95)' : 'scale(1)',
transition: 'transform 0.1s ease-out',
}}
>
{children}
{isPressed && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: `radial-gradient(circle, rgba(59, 130, 246, 0.3) ${
progress * 100
}%, transparent ${progress * 100}%)`,
borderRadius: 'inherit',
pointerEvents: 'none',
}}
/>
)}
</div>
);
}
10. リアルタイム通信連動エフェクト
WebSocket や Server-Sent Events を活用したリアルタイム通信と連動するアニメーションです。
typescriptimport { useState, useEffect, useRef, useCallback } from 'react';
interface NotificationData {
id: string;
type: 'success' | 'warning' | 'error' | 'info';
message: string;
timestamp: number;
}
interface RealTimeNotificationProps {
websocketUrl?: string;
}
function RealTimeNotificationSystem({ websocketUrl }: RealTimeNotificationProps) {
const [notifications, setNotifications] = useState<NotificationData[]>([]);
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'connecting'>('disconnected');
const wsRef = useRef<WebSocket | null>(null);
const addNotification = useCallback((notification: NotificationData) => {
setNotifications(prev => [...prev, notification]);
// 自動削除
setTimeout(() => {
setNotifications(prev => prev.filter(n => n.id !== notification.id));
}, 5000);
}, []);
const connectWebSocket = useCallback(() => {
if (!websocketUrl) return;
setConnectionStatus('connecting');
try {
wsRef.current = new WebSocket(websocketUrl);
wsRef.current.onopen = () => {
setConnectionStatus('connected');
console.log('WebSocket connected');
};
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addNotification({
id: `notification-${Date.now()}`,
type: data.type || 'info',
message: data.message,
timestamp: Date.now()
});
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
wsRef.current.onclose = () => {
setConnectionStatus('disconnected');
console.log('WebSocket disconnected');
// 自動再接続
setTimeout(connectWebSocket, 3000);
};
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error);
setConnectionStatus('disconnected');
};
} catch (error) {
console.error('Failed to connect WebSocket:', error);
setConnectionStatus('disconnected');
}
}, [websocketUrl, addNotification]);
useEffect(() => {
connectWebSocket();
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connectWebSocket]);
// デモ用の模擬通知生成
const generateMockNotification = () => {
const types: NotificationData['type'][] = ['success', 'warning', 'error', 'info'];
const messages = [
'New user registered',
'Payment processed successfully',
'Server maintenance scheduled',
'New message received'
];
addNotification({
id: `mock-${Date.now()}`,
type: types[Math.floor(Math.random() * types.length)],
message: messages[Math.floor(Math.random() * messages.length)],
timestamp: Date.now()
});
};
return (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 1000 }}>
{/* 接続状態インジケーター */}
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '10px',
padding: '8px 12px',
backgroundColor: connectionStatus === 'connected' ? '#10b981' : '#ef4444',
color: 'white',
borderRadius: '20px',
fontSize: '12px',
fontWeight: 'bold'
}}>
<div style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'white',
marginRight: '8px',
animation: connectionStatus === 'connecting' ? 'pulse 1s infinite' : 'none'
}} />
{connectionStatus.toUpperCase()}
</div>
{/* デモボタン */}
<button
onClick={generateMockNotification}
style={{
marginBottom: '10px',
padding: '8px 16px',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer'
}}
>
Test Notification
</button>
{/* 通知リスト */}
<div style={{ maxWidth: '300px' }}>
{notifications.map((notification, index) => (
<NotificationItem
key={notification.id}
notification={notification}
index={index}
/>
))}
</div>
</div>
);
}
function NotificationItem({
notification,
index
}: {
notification: NotificationData;
index: number;
}) {
const [isVisible, setIsVisible] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
useEffect(() => {
// エントランスアニメーション
const timer = setTimeout(() => setIsVisible(true), index * 100);
return () => clearTimeout(timer);
}, [index]);
useEffect(() => {
// 自動削除アニメーション
const timer = setTimeout(() => {
setIsRemoving(true);
}, 4500);
return () => clearTimeout(timer);
}, []);
const getNotificationStyle = () => {
const baseStyle = {
position: 'relative' as const,
padding: '12px 16px',
marginBottom: '8px',
borderRadius: '8px',
color: 'white',
fontSize: '14px',
transform: isVisible ? 'translateX(0)' : 'translateX(100%)',
opacity: isRemoving ? 0 : (isVisible ? 1 : 0),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden' as const,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
};
const colors = {
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6'
};
return {
...baseStyle,
backgroundColor: colors[notification.type]
};
};
return (
<div style={getNotificationStyle()}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
{notification.type.toUpperCase()}
</div>
<div>{notification.message}</div>
{/* プログレスバー */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
height: '3px',
backgroundColor: 'rgba(255, 255, 255, 0.3)',
animation: 'shrink 5s linear forwards'
}}
/>
</div>
);
}
// グローバルCSS(実際のプロジェクトでは別ファイルに)
const globalStyles = `
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
## よく遭遇するエラーとその解決法
### Memory Leak エラー
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
scss
**解決法:**
```typescript
useEffect(() => {
const animation = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animation); // 必ずクリーンアップ
};
}, []);
Performance Warning
csharp[Violation] 'requestAnimationFrame' handler took 35ms
Warning: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
解決法:
typescript// ❌ 悪い例:毎フレーム重い計算
useFrame(() => {
const expensiveCalculation = heavyFunction(); // 重い処理
setState(expensiveCalculation);
});
// ✅ 良い例:計算結果をメモ化
const memoizedValue = useMemo(
() => heavyFunction(),
[dependency]
);
useFrame(() => {
setState(memoizedValue);
});
まとめ
JavaScript アニメーションは CSS では実現できない高度な表現力を持っており、React アプリケーションのユーザーエクスペリエンスを飛躍的に向上させることができます。
使い分けの指針
# | アニメーション種類 | 適用場面 | パフォーマンス | 実装難易度 |
---|---|---|---|---|
1 | リアルタイム数値カウンター | ダッシュボード、統計表示 | ⭐⭐⭐ | ⭐⭐ |
2 | パーティクル爆発エフェクト | 成功フィードバック、ゲーム要素 | ⭐⭐ | ⭐⭐⭐ |
3 | インフィニットスクロール連動 | 長いリスト、記事ページ | ⭐⭐⭐ | ⭐⭐ |
4 | ドラッグ&ドロップ with 物理演算 | インタラクティブな操作 | ⭐⭐ | ⭐⭐⭐⭐ |
5 | 状態変化に連動するモーフィング | ボタン、フォーム要素 | ⭐⭐⭐ | ⭐⭐ |
6 | マウス追従インタラクション | ヒーローセクション、プロダクト | ⭐⭐ | ⭐⭐⭐ |
7 | タイムライン制御アニメーション | 複雑な演出、ストーリーテリング | ⭐⭐ | ⭐⭐⭐⭐⭐ |
8 | データ可視化チャートアニメーション | 分析画面、レポート | ⭐⭐⭐ | ⭐⭐⭐ |
9 | ジェスチャー認識アニメーション | モバイルアプリ、タッチ UI | ⭐⭐⭐ | ⭐⭐⭐⭐ |
10 | リアルタイム通信連動エフェクト | 通知システム、ライブ機能 | ⭐⭐ | ⭐⭐⭐⭐ |
ベストプラクティス
パフォーマンス最適化
-
requestAnimationFrame の適切な使用
- setInterval より requestAnimationFrame を使用
- 不要なアニメーションは cancelAnimationFrame でキャンセル
-
メモリリーク対策
- useEffect のクリーンアップ関数で必ずリソース解放
- イベントリスナーの適切な削除
-
DOM 操作の最小化
- transform プロパティを優先使用
- layout を発生させるプロパティの変更を避ける
TypeScript 活用
typescript// 型安全なアニメーション設定
interface AnimationConfig {
duration: number;
easing: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
delay?: number;
}
function createAnimation<T extends HTMLElement>(
element: T,
config: AnimationConfig
): Promise<void> {
return new Promise((resolve) => {
// アニメーション実装
});
}
デバッグとテスト
typescript// アニメーション完了を検知するカスタムフック
function useAnimationEnd(ref: RefObject<HTMLElement>) {
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleAnimationEnd = () => setIsAnimating(false);
element.addEventListener(
'animationend',
handleAnimationEnd
);
return () => {
element.removeEventListener(
'animationend',
handleAnimationEnd
);
};
}, [ref]);
return { isAnimating, setIsAnimating };
}
2025 年現在、Web ブラウザーのパフォーマンスは大幅に向上し、JavaScript アニメーションの可能性は無限大に広がっています。今回ご紹介した 10 の手法を組み合わせることで、ユーザーの期待を超える魅力的な Web アプリケーションを構築できるでしょう。
ぜひ実際のプロジェクトでこれらの技術を試してみて、React アプリケーションの表現力を向上させてくださいね。
関連リンク
- article
CSS だけじゃ物足りない!React アプリを動かす JS アニメーション 10 選
- article
React × Three.js で実現するインタラクティブ 3D アニメーション最前線
- article
htmx と React/Vue/Svelte の違いを徹底比較
- article
あなたの Jotai アプリ、遅くない?React DevTools と debugLabel でボトルネックを特定し、最適化する手順
- article
ReactのJSX をそのまま使える!SolidJS の独自構文を体感しよう
- article
3 分で理解!React Spring を使った自然なアニメーション入門
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体
- review
瞬時に答えが出る脳に変身!『ゼロ秒思考』赤羽雄二が贈る思考力爆上げトレーニング
- review
関西弁のゾウに人生変えられた!『夢をかなえるゾウ 1』水野敬也が教えてくれた成功の本質