Wind-Blown Headline
A headline split into per-character cells, with each cell offset by a slightly different animation-delay on the same keyframes so the sway travels across the line like a passing gust. A horizontal sway and a vertical lift are layered at durations that do not divide evenly (3.7s / 5.3s) with different per-character delay steps (0.09s / 0.13s), so the phases never re-align and the loop stops reading as a loop. Amplitude is distributed as a per-character custom property, keeping a single set of keyframes while allowing a flag-like ramp where the left edge is anchored and motion grows toward the right. Splitting happens at render time (swapping the DOM afterwards re-flows the line the moment the cells become inline-block), and only transform is animated, so line spacing and neighbouring elements never move. The one real pitfall of per-character inline-block — changed line-break opportunities — is handled by keeping Latin words in a single cell and absorbing punctuation that must not start a line into the preceding cell. Gusts run as a one-shot animation on a separate layer, and no randomness is used during render. Zero dependencies; downgrades to a still headline under prefers-reduced-motion.
- Added:
- 2026-08-22
- Dependencies:
- None
- tags
- #typography #heading #wind #stagger #css-animation #custom-property #no-layout-shift #no-dependency #reduced-motion
Preview
Wind Type / TYP-03
白い余白に、風の通り道をつくる。 CARRIED BY THE AIR
見出しを1文字ずつのマスに分け、同じ揺れに少しずつ違う遅延を配ることで、風が列を横切っていくように見せています。動かすのは transform だけなので、行送りも隣の要素も1pxも動きません。
Wind
Amplitude
Gust
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
/**
* 風に流れる見出しテキスト(TYP-03)
*
* 汎用技法メモ(web-design-playbook 還流用):
*
* 1. 「風」は振幅ではなく位相のズレで作る
* 文字を1字ずつに割り、同じキーフレームへ **文字ごとに少しずつ違う animation-delay** を
* 与えると、揺れが列を横切って伝わる=突風が抜けていくように見える。
* さらに横揺れ(sway)と縦揺れ(lift)を **互いに割り切れない周期**(3.7s / 5.3s)で
* 重ね、遅延の刻みも別(0.09s / 0.13s)にすると、位相が噛み合う瞬間が来ないため
* ループ感が消える。乱数は一切使わない(=SSRとクライアントで同じ絵になる)。
*
* 2. 振幅は1文字ごとの custom property で配る
* キーフレームは1組だけ書き、`transform: translate3d(calc(var(--wbt-amp) * 0.1em), ...)`
* のように **CSS変数を掛ける**。文字ごとの style には `--i`(順番)と `--wbt-amp`(振幅)
* だけを載せる。旗のように「左を固定して右へ行くほど大きく揺れる」も、
* 振幅を index/count で配るだけで済み、JSは何もアニメーションしない。
*
* 3. 分解してもレイアウトを1pxも動かさない
* - 分割は **レンダリング時**に行う(useEffect で後からDOMを差し替えると、
* その瞬間に inline-block 化が起きて行が組み直され、必ずガタつく)。
* - 動かすのは transform / opacity だけ。行送りにも隣接要素にも影響しない。
* - 1字ずつ inline-block にすると **改行位置の判断が変わる**のが唯一の落とし穴。
* 欧文は単語単位でまとめて1マスにし(単語内改行を防ぐ)、和文は行頭に来ては
* いけない約物(、。」)を直前のマスへ吸収させて禁則を保つ。
* - 揺れた字が親の overflow で切れないよう、見出しの箱には縦横の padding を持たせる
* (transform は箱を広げないので、余白がないと上下の端が欠ける)。
*
* 4. 突風(一発もの)は sway とは別レイヤーに載せる
* 同じ要素に transform を変える animation を2本掛けると後勝ちで片方が消える。
* 1文字=「突風(gust)」>「横揺れ(sway)」>「縦揺れ(lift)」の3重の inline-block にし、
* それぞれが自分の transform を持つと合成される。再発火はクラスを外し
* `void el.offsetWidth` で強制リフローしてから付け直す(同名アニメは再start しない)。
*
* 5. prefers-reduced-motion は完全停止(=静止した見出しへの降格)
* transform しか使っていないので、animation を切れば元の1行に戻り、文章は壊れない。
* 自動で吹く突風のタイマーも matchMedia を購読して止める。
* 分解した行は aria-hidden にし、素のテキストを sr-only で別に置く。
*/
/** 行頭に置けない約物。直前のマスへ吸収させて禁則を保つ */
const NO_LINE_START = "、。,.・)」』】〉》”’ー?!";
/** 欧文の単語・数字は1マスにまとめる(単語の途中で改行させない) */
const WORDISH = /[0-9A-Za-z'’&.-]/;
type Cell = { text: string; space: boolean };
/**
* 1行を「揺れる最小単位(マス)」へ割る。
* 和文は1文字=1マス、欧文は1単語=1マス、空白は素の inline のまま残して改行機会にする。
*/
function toCells(line: string): Cell[] {
const cells: Cell[] = [];
for (const char of Array.from(line)) {
const prev = cells[cells.length - 1];
if (char === " " || char === " ") {
cells.push({ text: char, space: true });
continue;
}
if (prev && !prev.space && (NO_LINE_START.includes(char) || (WORDISH.test(char) && WORDISH.test(prev.text.slice(-1))))) {
prev.text += char;
continue;
}
cells.push({ text: char, space: false });
}
return cells;
}
const WINDS = [
{ key: "breeze", label: "そよ風", gain: 0.55, dur: 4.6, gustEvery: 9000 },
{ key: "wind", label: "風", gain: 1, dur: 3.7, gustEvery: 6200 },
{ key: "gale", label: "強風", gain: 1.75, dur: 2.9, gustEvery: 3800 },
] as const;
type WindKey = (typeof WINDS)[number]["key"];
const LINES = [
{ text: "風の通り道をつくる。", size: "text-[clamp(1.5rem,6.6vw,2.6rem)] leading-[1.55]" },
{ text: "CARRIED BY THE AIR", size: "font-display text-[clamp(0.75rem,2.6vw,1.05rem)] tracking-[0.3em] leading-[2]" },
];
export default function WindBlownText() {
// 一覧ページでは同じ標本が複数描画されるので id は必ず発番する
const uid = useId();
const stageRef = useRef<HTMLDivElement>(null);
const [wind, setWind] = useState<WindKey>("wind");
const [anchored, setAnchored] = useState(true);
const preset = WINDS.find((w) => w.key === wind) ?? WINDS[1];
// 突風: クラスを外して強制リフローしてから付け直す(同名アニメは再startしないため)
const blow = useCallback(() => {
const stage = stageRef.current;
if (!stage) return;
stage.classList.remove("wbt-gusting");
void stage.offsetWidth;
stage.classList.add("wbt-gusting");
}, []);
// 自動の突風。reduced-motion では吹かせない(OS設定の変更にも追随する)
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let timer = 0;
const stop = () => {
window.clearTimeout(timer);
stageRef.current?.classList.remove("wbt-gusting");
};
const schedule = () => {
// 間隔をわずかに散らすと「一定周期で吹く」機械っぽさが消える
timer = window.setTimeout(() => {
blow();
schedule();
}, preset.gustEvery * (0.7 + Math.random() * 0.6));
};
const sync = () => {
stop();
if (!mq.matches) schedule();
};
sync();
mq.addEventListener("change", sync);
return () => {
stop();
mq.removeEventListener("change", sync);
};
}, [blow, preset.gustEvery]);
const chip = (active: boolean) =>
`rounded-full border px-3 py-1 text-xs font-semibold transition-colors duration-200 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
active
? "border-amber-600 bg-amber-600 text-white"
: "border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-900"
}`;
return (
<section
aria-labelledby={`${uid}-title`}
className="w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
>
<div className="px-5 pt-8 sm:px-10 sm:pt-10">
<p className="font-display text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase">
Wind Type <span className="text-gray-300">/</span>{" "}
<span className="text-gray-400">TYP-03</span>
</p>
<h2 id={`${uid}-title`} className="mt-5">
<span className="block text-sm font-semibold tracking-[0.06em] text-gray-500 sm:text-base">
白い余白に、
</span>
{/* 素のテキスト。分解した行は1字ずつ読み上げさせない */}
<span className="sr-only">{LINES.map((l) => l.text).join(" ")}</span>
<div
ref={stageRef}
aria-hidden="true"
data-wind={wind}
style={
{
"--wbt-gain": preset.gain,
"--wbt-dur": `${preset.dur}s`,
} as React.CSSProperties
}
className="wbt-stage mt-2 border-b border-amber-600/30 px-1 py-2 font-bold tracking-tight text-gray-900"
>
{LINES.map((line) => {
const cells = toCells(line.text);
return (
<span key={line.text} className={`block ${line.size}`}>
{cells.map((cell, i) =>
cell.space ? (
// 空白は素の inline のまま。ここが唯一の改行機会になる
<span key={`${line.text}-${i}`}>{cell.text}</span>
) : (
<span
key={`${line.text}-${i}`}
style={
{
"--i": i,
// 旗のように左を固定して右へ行くほど大きく揺らす/全文字一律に揺らす
"--wbt-amp": anchored
? (0.2 + (0.9 * i) / Math.max(cells.length - 1, 1)).toFixed(3)
: 1,
} as React.CSSProperties
}
className="wbt-cell"
>
<span className="wbt-sway">
<span className="wbt-lift">{cell.text}</span>
</span>
</span>
),
)}
</span>
);
})}
</div>
</h2>
<p className="mt-6 max-w-lg text-[13px] leading-[1.95] text-gray-600">
見出しを1文字ずつのマスに分け、同じ揺れに少しずつ違う遅延を配ることで、風が列を横切っていくように見せています。動かすのは transform だけなので、行送りも隣の要素も1pxも動きません。
</p>
</div>
<div className="mt-8 flex flex-col gap-5 border-t border-gray-200 px-5 py-6 sm:flex-row sm:flex-wrap sm:gap-x-10 sm:px-10">
<div role="group" aria-labelledby={`${uid}-wind`}>
<p
id={`${uid}-wind`}
className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase"
>
Wind
</p>
<div className="mt-2 flex gap-2">
{WINDS.map((w) => (
<button
key={w.key}
type="button"
aria-pressed={wind === w.key}
onClick={() => setWind(w.key)}
className={chip(wind === w.key)}
>
{w.label}
</button>
))}
</div>
</div>
<div>
<p className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">Amplitude</p>
<button
type="button"
aria-pressed={anchored}
onClick={() => setAnchored((v) => !v)}
className={`mt-2 inline-flex items-center gap-2 ${chip(anchored)}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${anchored ? "bg-white" : "bg-gray-300"}`}
/>
旗のように増幅
</button>
</div>
<div>
<p className="text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">Gust</p>
<button
type="button"
onClick={blow}
className="mt-2 rounded-full border border-gray-900 bg-gray-900 px-3 py-1 text-xs font-semibold text-white transition-colors duration-200 hover:bg-gray-700 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
突風を送る
</button>
</div>
</div>
{/* 現在の設定。位相の刻みが「風」の正体であることを見せる */}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-gray-200 bg-gray-50/70 px-5 py-4 font-mono text-[11px] leading-relaxed text-gray-500 sm:px-10">
<span className="whitespace-nowrap">
sway=<span className="text-amber-700">{preset.dur.toFixed(1)}s</span>
</span>
<span className="whitespace-nowrap">
lift=<span className="text-amber-700">{(preset.dur * 1.43).toFixed(1)}s</span>
</span>
<span className="whitespace-nowrap">
delay/char=<span className="text-amber-700">-0.09s</span>
</span>
<span className="whitespace-nowrap">
gain=<span className="text-amber-700">{preset.gain}</span>
</span>
<span className="whitespace-nowrap">
amp=<span className="text-amber-700">{anchored ? "0.2→1.1" : "1.0"}</span>
</span>
</div>
{/* 揺れのエンジンはすべてここ。文字側の style は --i と --wbt-amp だけを配る(クラス接頭辞 wbt-) */}
<style>{`
.wbt-cell,
.wbt-cell .wbt-sway,
.wbt-cell .wbt-lift {
display: inline-block;
will-change: transform;
}
.wbt-cell .wbt-sway {
animation: wbt-sway var(--wbt-dur) ease-in-out infinite;
animation-delay: calc(var(--i) * -0.09s);
}
/* 縦揺れは横揺れと割り切れない周期・違う刻みにする(位相が噛み合わずループ感が消える) */
.wbt-cell .wbt-lift {
animation: wbt-lift calc(var(--wbt-dur) * 1.43) ease-in-out infinite;
animation-delay: calc(var(--i) * -0.13s - 0.4s);
}
.wbt-stage.wbt-gusting .wbt-cell {
animation: wbt-gust 1.15s cubic-bezier(0.22, 0.9, 0.3, 1) both;
animation-delay: calc(var(--i) * 0.028s);
}
@keyframes wbt-sway {
0%, 100% { transform: translate3d(calc(var(--wbt-amp) * var(--wbt-gain) * -0.05em), 0, 0) rotate(calc(var(--wbt-amp) * var(--wbt-gain) * -0.9deg)); }
50% { transform: translate3d(calc(var(--wbt-amp) * var(--wbt-gain) * 0.08em), 0, 0) rotate(calc(var(--wbt-amp) * var(--wbt-gain) * 1.2deg)); }
}
@keyframes wbt-lift {
0%, 100% { transform: translate3d(0, calc(var(--wbt-amp) * var(--wbt-gain) * 0.09em), 0); }
50% { transform: translate3d(0, calc(var(--wbt-amp) * var(--wbt-gain) * -0.12em), 0); }
}
@keyframes wbt-gust {
0% { transform: none; }
22% { transform: translate3d(calc(var(--wbt-amp) * var(--wbt-gain) * 0.34em), calc(var(--wbt-amp) * var(--wbt-gain) * -0.26em), 0) rotate(calc(var(--wbt-amp) * var(--wbt-gain) * 6deg)); }
58% { transform: translate3d(calc(var(--wbt-amp) * var(--wbt-gain) * -0.1em), calc(var(--wbt-amp) * var(--wbt-gain) * 0.06em), 0) rotate(calc(var(--wbt-amp) * var(--wbt-gain) * -2deg)); }
100% { transform: none; }
}
/* 停止すれば transform が外れて元の1行に戻る(文章は壊れない) */
@media (prefers-reduced-motion: reduce) {
.wbt-cell,
.wbt-cell .wbt-sway,
.wbt-cell .wbt-lift {
animation: none !important;
will-change: auto;
}
}
`}</style>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/wind-blown-text.json