スクロール

CSSとGSAPで作る、スクロールで文字が散らばるテキストアニメーション

投稿日2026/07/13

更新日2026/7/12

スクロールに合わせて文字がバラバラに飛んだり、逆に集まって完成したりするテキストアニメーションです。

CSSとGSAP(ScrollTrigger)を組み合わせることで、風に吹かれるような自然な動きを実現しています。飛ぶ方向や距離、回転量、時間差なども自由に変更できるため、ファーストビューやコンテンツの見出しなど、さまざまなシーンで活用できます。

Code コード

<div class="kumonosu-container">
	<section class="kumonosu-intro">
		<div class="kumonosu-intro-inner">
			<p class="kumonosu-sec-label">はじめる</p>
			<p class="kumonosu-scroll-cue kumonosu-fly-text" data-start-y=".55">下にスクロールして文字を散らしてください</p>
		</div>
	</section>
	<section class="kumonosu-sec">
		<p class="kumonosu-sec-label">文字を飛ばす</p>
		<h1 class="kumonosu-fly-text" data-wind-angle="18" data-wind-strength="550" data-scatter="100" data-max-rotation="480" data-stagger=".7" data-depth="160" data-animation-duration=".6" data-start-y=".5"> 風に吹かれたように、文字がバラバラに飛んでいきます </h1>
	</section>
	<section class="kumonosu-sec kumonosu-sec--body">
		<p class="kumonosu-sec-label">動きの設定</p>
		<p class="kumonosu-fly-text" data-wind-angle="120" data-wind-strength="50" data-scatter="50" data-max-rotation="570" data-stagger="1.5" data-order="outward" data-randomness=".2" data-gustiness="200" data-gust-frequency=".4" data-gust-phase-spread=".4" data-depth="290" data-start-y=".7" data-animation-duration=".7"> 文字が散らばる向きや強さは、自由に設定できます。スクロールの動きに合わせて、文字がゆっくり漂ったり、勢いよく吹き飛ばされたりします。一文字ずつ時間差をつけて動かすことで、バラバラに崩れていくような自然な演出が可能です。 </p>
	</section>
	<section class="kumonosu-sec">
		<p class="kumonosu-sec-label">逆方向に動かす</p>
		<h2 class="kumonosu-fly-text" data-reverse="true" data-order="ltr" data-wind-angle="150" data-wind-strength="500" data-scatter="90" data-max-rotation="450" data-gustiness="120" data-stagger=".9" data-depth="140" data-start-y=".9" data-animation-duration=".7" data-easing="elastic.out(1,0.7)">
			<pre>data-reverse</pre> を使うと、バラバラの文字が集まって完成します
		</h2>
	</section>
	<section class="kumonosu-sec">
		<p class="kumonosu-sec-label">まとめ</p>
		<h2 class="kumonosu-fly-text" data-wind-angle="42" data-wind-strength="700" data-scatter="350" data-max-rotation="720" data-stagger=".3" data-depth="220"> スクロールと連動して、印象的なWebサイトを作れます </h2>
	</section>
	<footer class="kumonosu-outro">
		<p class="kumonosu-sec-label">おわり</p>
	</footer>
</div>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&family=Noto+Sans+JP:wght@400;700;900&display=swap');
/* ブラウザの基本設定リセット */
html, body {
	margin: 0;
	padding: 0;
	width: 100%;
}
/* bodyの中の全体を囲むラッパー要素に装飾を適用 */
.kumonosu-container {
	background: #f0efe9;
	color: #1a1917;
	font-family: "Inter", "Noto Sans JP", sans-serif;
	overflow-x: hidden;
	width: 100%;
	font-size: 16px;
}
.kumonosu-intro {
	min-height: 100vh;
	display: flex;
	align-items: center;
	justify-content: center;
	border-bottom: 1px solid #d8d5cc;
}
.kumonosu-intro-inner {
	text-align: center;
}
.kumonosu-sec {
	min-height: 100vh;
	display: flex;
	flex-direction: column;
	justify-content: center;
	max-width: 900px;
	margin: 0 auto;
	padding: 6rem 2rem;
	border-bottom: 1px solid #d8d5cc;
}
.kumonosu-sec--body {
	max-width: 720px;
}
.kumonosu-outro {
	display: flex;
	justify-content: center;
	align-items: flex-start;
	min-height: 60vh;
	padding: 6rem 2rem;
}
.kumonosu-scroll-cue {
	font-size: clamp(1.2rem, 2.5vw, 1.6rem);
	font-weight: 500;
	color: #706e69;
	animation: kumonosu-breathe 2.5s ease-in-out infinite;
}
@keyframes kumonosu-breathe {
	0%, 100% {
		opacity: 0.3;
	}
	50% {
		opacity: 1;
	}
}
.kumonosu-sec-label {
	font-size: 0.8rem;
	color: #908d85;
	margin-bottom: 1.5rem;
	font-weight: bold;
}
h1, h2 {
	line-height: 1.4;
	font-weight: 900;
	margin: 0;
}
pre {
	display: inline;
	background: rgba(0, 0, 0, 0.06);
	padding: 0.2em 0.4em;
	border-radius: 4px;
	font-size: 0.9em;
}
.kumonosu-sec h1 {
	font-size: clamp(2rem, 7vw, 4.5rem);
}
.kumonosu-sec h2 {
	font-size: clamp(1.8rem, 6vw, 3.5rem);
}
.kumonosu-sec p.kumonosu-fly-text {
	font-size: clamp(1.1rem, 2vw, 1.3rem);
	line-height: 1.8;
}
.kumonosu-fly-text {
	position: relative;
}
.kumonosu-fly-char {
	will-change: transform, opacity;
	display: inline-block;
}
.kumonosu-visually-hidden {
	position: absolute;
	width: 1px;
	height: 1px;
	padding: 0;
	margin: -1px;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}
import {
	gsap
} from "https://esm.sh/gsap@3.12.5";
import {
	ScrollTrigger
} from "https://esm.sh/gsap@3.12.5/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const DEFAULTS = {
	windAngle: 25,
	windStrength: 400,
	scatter: 80,
	maxRotation: 360,
	stagger: 0.5,
	depth: 120,
	reverse: false,
	order: "random",
	randomness: 0,
	gustiness: 0,
	gustFrequency: 1,
	gustPhaseSpread: 1,
	startY: null,
	animationDuration: 1,
	easing: null,
};

function sfc32(a, b, c, d) {
	return function() {
		a |= 0;
		b |= 0;
		c |= 0;
		d |= 0;
		const t = (a + b | 0) + d | 0;
		d = d + 1 | 0;
		a = b ^ (b >>> 9);
		b = c + (c << 3) | 0;
		c = (c << 21) | (c >>> 11);
		c = c + t | 0;
		return (t >>> 0) / 4294967296;
	};
}

function seededRandom(seed) {
	let s = seed >>> 0;
	const splitmix32 = () => {
		s = (s + 0x9e3779b9) | 0;
		let t = s ^ (s >>> 16);
		t = Math.imul(t, 0x21f0aaad);
		t = t ^ (t >>> 15);
		t = Math.imul(t, 0x735a2d97);
		return (t ^ (t >>> 15)) >>> 0;
	};
	const rand = sfc32(splitmix32(), splitmix32(), splitmix32(), splitmix32());
	for (let i = 0; i < 12; i++) rand();
	return rand;
}
const seed = 42;
let r = seededRandom(seed);
const rand = (min, max) => min + r() * (max - min);
const clamp01 = (v) => Math.max(0, Math.min(1, v));
const lerp = (a, b, t) => a + (b - a) * t;

function buildStructure(el, text) {
	if (getComputedStyle(el).position === "static") el.style.position = "relative";
	el.textContent = "";
	const srEl = document.createElement("span");
	srEl.className = "kumonosu-visually-hidden";
	srEl.textContent = text;
	el.appendChild(srEl);
	const placeholder = document.createElement("span");
	placeholder.setAttribute("aria-hidden", "true");
	placeholder.style.cssText = "visibility:hidden; pointer-events:none; user-select:none;";
	placeholder.textContent = text;
	el.appendChild(placeholder);
	const overlay = document.createElement("span");
	overlay.setAttribute("aria-hidden", "true");
	overlay.style.cssText = "position:absolute; top:0; left:0; width:100%; height:100%; overflow:visible; pointer-events:none;";
	el.appendChild(overlay);
	return {
		placeholder,
		overlay
	};
}

function measureAndCreateChars(el, raw, placeholder, overlay) {
	const containerRect = el.getBoundingClientRect();
	const textNode = placeholder.firstChild;
	const chars = [];
	for (let i = 0; i < raw.length; i++) {
		if (raw[i] === " " || raw[i] === " ") continue;
		const range = document.createRange();
		range.setStart(textNode, i);
		range.setEnd(textNode, i + 1);
		const rRect = range.getBoundingClientRect();
		const span = document.createElement("span");
		span.textContent = raw[i];
		span.classList.add("kumonosu-fly-char");
		span.style.cssText = `position:absolute; left:${rRect.left - containerRect.left}px; top:${rRect.top - containerRect.top}px; width:${rRect.width}px; height:${rRect.height}px; white-space:nowrap;`;
		span._x = rRect.left - containerRect.left;
		overlay.appendChild(span);
		chars.push(span);
	}
	const xs = chars.map((c) => c._x);
	const xMin = Math.min(...xs);
	const xRange = Math.max(...xs) - xMin || 1;
	chars.forEach((c) => {
		c._normX = (c._x - xMin) / xRange;
	});
	return chars;
}

function charStartTime(char, total, order, stagger, randomness = 0) {
	if (total <= 1) return 0;
	const x = char._normX;
	let ordered;
	switch (order) {
		case "ltr":
			ordered = x * stagger;
			break;
		case "rtl":
			ordered = (1 - x) * stagger;
			break;
		case "outward":
			ordered = (1 - Math.abs(x - 0.5) * 2) * stagger;
			break;
		default:
			return rand(0, stagger);
	}
	return ordered * (1 - randomness) + rand(0, stagger) * randomness;
}

function buildTimeline(el, chars, p) {
	const {
		reverse,
		windAngle,
		windStrength,
		scatter,
		maxRotation,
		depth,
		order,
		stagger,
		randomness,
		gustiness,
		gustFrequency,
		gustPhaseSpread,
		easing
	} = p;
	const tl = gsap.timeline({
		paused: true
	});
	if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
		tl.fromTo(el, {
			opacity: reverse ? 0 : 1
		}, {
			opacity: reverse ? 1 : 0,
			duration: 1
		});
		return tl;
	}
	const rad = (windAngle * Math.PI) / 180;
	const windX = Math.cos(rad);
	const windY = -Math.sin(rad);
	const perpX = Math.sin(rad);
	const perpY = Math.cos(rad);
	const sharedAmp = gustiness > 0 ? rand(0.1, 1.0) * gustiness * (r() > 0.5 ? 1 : -1) : 0;
	chars.forEach((char, i) => {
		const startTime = charStartTime(char, chars.length, order, stagger, randomness);
		const duration = rand(1 - randomness * 0.5, 1 + randomness * 0.5);
		const scatterAngle = rand(0, Math.PI * 2);
		const scatterDist = rand(0, scatter);
		const syncPhase = Math.PI * gustFrequency * startTime;
		const indexPhase = (i / Math.max(1, chars.length - 1)) * Math.PI * 2;
		const phase = lerp(syncPhase, indexPhase, gustPhaseSpread);
		const fx = windX * windStrength + Math.cos(scatterAngle) * scatterDist;
		const fy = windY * windStrength + Math.sin(scatterAngle) * scatterDist;
		const fz = rand(-depth, depth);
		const rx = rand(-maxRotation, maxRotation);
		const ry = rand(-maxRotation * 0.7, maxRotation * 0.7);
		const rz = rand(-maxRotation * 0.3, maxRotation * 0.3);
		if (gustiness > 0) {
			const individualAmp = rand(0.1, 1.0) * gustiness * (r() > 0.5 ? 1 : -1);
			const amp = lerp(sharedAmp, individualAmp, gustPhaseSpread);
			const s0 = Math.sin(phase);
			const s1 = Math.sin(Math.PI * gustFrequency + phase);
			const gustSine = (t) => amp * (Math.sin(Math.PI * gustFrequency * t + phase) - s0 - t * (s1 - s0));
			const sineAt = (t) => {
				const s = reverse ? 1 - t : t;
				return {
					x: s * fx + perpX * gustSine(t),
					y: s * fy + perpY * gustSine(t),
					z: s * fz,
					rotationX: rx * s,
					rotationY: ry * s,
					rotationZ: rz * s,
					opacity: clamp01((1 - s) / 0.6)
				};
			};
			gsap.set(char, sineAt(0));
			const proxy = {
				t: 0
			};
			tl.to(proxy, {
				t: 1,
				duration,
				ease: easing || "power3.in",
				onUpdate() {
					gsap.set(char, sineAt(proxy.t));
				}
			}, startTime);
		} else {
			const from = reverse ? {
				x: fx,
				y: fy,
				z: fz,
				rotationX: rx,
				rotationY: ry,
				rotationZ: rz,
				opacity: 0
			} : {
				x: 0,
				y: 0,
				z: 0,
				rotationX: 0,
				rotationY: 0,
				rotationZ: 0,
				opacity: 1
			};
			const to = reverse ? {
				x: 0,
				y: 0,
				z: 0,
				rotationX: 0,
				rotationY: 0,
				rotationZ: 0,
				opacity: 1
			} : {
				x: fx,
				y: fy,
				z: fz,
				rotationX: rx,
				rotationY: ry,
				rotationZ: rz,
				opacity: 0
			};
			tl.fromTo(char, from, {
				...to,
				duration,
				ease: easing ?? (reverse ? "power3.out" : "power3.in")
			}, startTime);
		}
	});
	const animDuration = parseFloat(p.animationDuration);
	if (animDuration > 0 && animDuration < 1) tl.call(() => {}, [], tl.duration() / animDuration);
	return tl;
}

function initFlyText(el) {
	const d = el.dataset;
	const p = {
		...DEFAULTS
	};
	for (const key in d) {
		if (d[key] === "true") p[key] = true;
		else if (d[key] === "false") p[key] = false;
		else if (!isNaN(d[key]) && d[key].trim() !== "") p[key] = parseFloat(d[key]);
		else p[key] = d[key];
	}
	const raw = el.textContent.replace(/\s+/g, " ").trim();
	const {
		placeholder,
		overlay
	} = buildStructure(el, raw);
	let st = null,
		tl = null;

	function setup() {
		if (st) st.kill();
		if (tl) tl.kill();
		overlay.innerHTML = "";
		const chars = measureAndCreateChars(el, raw, placeholder, overlay);
		if (!chars.length) return;
		gsap.set(chars, {
			transformPerspective: 500
		});
		tl = buildTimeline(el, chars, p);
		const startPct = Math.round((p.startY ?? (p.reverse ? 0.85 : 0.65)) * 100);
		st = ScrollTrigger.create({
			trigger: el,
			start: `top ${startPct}%`,
			end: p.reverse ? "top 20%" : "bottom top",
			scrub: 1,
			animation: tl
		});
	}
	setup();
	let resizeTimer;
	new ResizeObserver(() => {
		r = seededRandom(seed);
		clearTimeout(resizeTimer);
		resizeTimer = setTimeout(setup, 200);
	}).observe(el);
}
document.fonts.ready.then(() => {
	document.querySelectorAll(".kumonosu-fly-text").forEach(initFlyText);
});

Explanation 詳しい説明

概要

スクロール位置に応じて、一文字ずつ文字が風に吹かれたように飛び散るテキストアニメーションです。GSAPのScrollTriggerを利用しているため、スクロール量とアニメーションが滑らかに同期します。

文字は自動で一文字ずつ分割されるため、HTML側で一文字ずつタグを付ける必要はありません。見出しだけでなく、本文やキャッチコピーにも利用できます。

主な仕様

  • スクロール量とアニメーションが連動
  • GSAPとScrollTriggerを使用
  • 一文字ずつ自動で分割してアニメーション
  • 風向きや飛ぶ方向を自由に変更可能
  • 飛距離や散らばる範囲を調整可能
  • 回転量や3D奥行きを設定可能
  • 文字ごとに時間差を付けたアニメーション
  • ランダムな動きにも対応
  • 逆再生で文字が集まる演出にも対応
  • レスポンシブ対応
  • prefers-reduced-motionに対応

カスタマイズできる項目

data属性だけで動きを細かく変更できます。

  • 風向き
  • 飛ぶ強さ
  • 散らばる範囲
  • 回転量
  • 奥行き(3D)
  • アニメーション開始位置
  • アニメーション時間
  • イージング
  • 文字の表示順
  • ランダム性
  • 風の揺らぎ
  • 逆方向アニメーション
  • スクロール開始位置

注意点

GSAPとScrollTriggerの読み込みが必要です。

スクロール位置に応じて大量の文字を個別にアニメーションさせるため、長文へ適用すると描画負荷が高くなる場合があります。見出しやキャッチコピーなど、比較的文字数の少ない要素への使用がおすすめです。

また、文字を一文字ずつ分割して配置する仕組みのため、JavaScriptが無効な環境ではアニメーションは動作しません。SEOやアクセシビリティに配慮した構造になっているため、スクリーンリーダーでも読み上げられるよう実装されています。