HTML / CSS / JS
スクロール
2026/07/13
2026/7/12
スクロールに合わせて文字がバラバラに飛んだり、逆に集まって完成したりするテキストアニメーションです。
CSSとGSAP(ScrollTrigger)を組み合わせることで、風に吹かれるような自然な動きを実現しています。飛ぶ方向や距離、回転量、時間差なども自由に変更できるため、ファーストビューやコンテンツの見出しなど、さまざまなシーンで活用できます。
<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);
});
スクロール位置に応じて、一文字ずつ文字が風に吹かれたように飛び散るテキストアニメーションです。GSAPのScrollTriggerを利用しているため、スクロール量とアニメーションが滑らかに同期します。
文字は自動で一文字ずつ分割されるため、HTML側で一文字ずつタグを付ける必要はありません。見出しだけでなく、本文やキャッチコピーにも利用できます。
data属性だけで動きを細かく変更できます。
GSAPとScrollTriggerの読み込みが必要です。
スクロール位置に応じて大量の文字を個別にアニメーションさせるため、長文へ適用すると描画負荷が高くなる場合があります。見出しやキャッチコピーなど、比較的文字数の少ない要素への使用がおすすめです。
また、文字を一文字ずつ分割して配置する仕組みのため、JavaScriptが無効な環境ではアニメーションは動作しません。SEOやアクセシビリティに配慮した構造になっているため、スクリーンリーダーでも読み上げられるよう実装されています。