First CH Designs
TYP-03Typography

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

sway=3.7slift=5.3sdelay/char=-0.09sgain=1amp=0.2→1.1
"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