デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- SplitText
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しを 1 文字ずつに分け、太さを時刻と文字の横位置から直接計算します。位置に応じて位相をずらすので、太い帯が左から右へ流れているように見えます。
太さは 100 から 900 の間を往復し、1 か所が往復するのに 3 秒かけます。波長は見出しの幅の 1.05 倍にしていて、値を小さくすると波が細かくなります。
太さが変わると字の幅も変わって行が揺れるので、文字を固定幅の箱に入れます。箱の幅は太さ 100 と 900 のときの字幅を測ってから決め、inline-flex にして中央にそろえます(text-align: center は箱からはみ出すと左詰めになります)。
可変フォントでない書体では太さが段階的にしか切り替わらないので、太さの範囲を指定した可変フォントを使ってください。和文の書体は太さごとに別の書体になっているものが多く、波が段々に切り替わるだけになるので、このデモの見出しは欧文で組んでいます。波の速さと波長、文字を収める箱の余裕は、どれも増減して調整できます。
書体やタイポグラフィを扱うブランドサイトのヒーローや、呼吸するようにゆっくり動かしたい見出しに向いています。文字の位置が上下に波打つ動きと違い、こちらは字形そのものの太さが変わります。
コード
<section class="weight-wave">
<h2 class="weight-wave__heading">Type that breathes.</h2>
<button class="weight-wave__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</section>
.weight-wave {
--bg: #f2f1ed;
--ink: #262626;
display: grid;
place-items: center;
gap: 32px;
min-height: 100vh;
padding: 96px 6vw;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.weight-wave__heading {
margin: 0;
font-size: clamp(40px, 9vw, 112px);
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.01em;
}
.weight-wave__toggle {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.weight-wave__toggle[aria-pressed="true"] {
background: var(--ink);
color: var(--bg);
}
.weight-wave__toggle:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
const LIGHT = 100; // いちばん細いとき
const HEAVY = 900; // いちばん太いとき
const BOX = 0.7; // 文字を収める箱の幅(0 = 細いときの字幅、1 = 太いときの字幅)
const WAVE = 1.05; // 波長(見出しの幅に対する比)。小さいほど波が細かい
const PERIOD = 3; // 1 文字の太さが 1 往復する時間(秒)
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// 字形が決まってから字幅を測る。仮の書体の幅で箱を作ると、書体が差し替わったときに字がずれる
document.fonts.ready.then(() => {
document.querySelectorAll(".weight-wave").forEach((section) => {
const heading = section.querySelector(".weight-wave__heading");
const toggle = section.querySelector(".weight-wave__toggle");
const split = SplitText.create(heading, { type: "words,chars" });
const chars = split.chars;
// 親の太さを 100 と 900 に替えて、文字ごとの字幅を測る
const widths = (weight) => {
heading.style.fontWeight = weight;
return chars.map((c) => c.getBoundingClientRect().width);
};
const light = widths(LIGHT);
const heavy = widths(HEAVY);
// 太さで字幅が変わると行が揺れるので、文字を固定幅の箱に入れて中央ぞろえにする
chars.forEach((c, i) => {
c.style.width = `${(light[i] + (heavy[i] - light[i]) * BOX).toFixed(2)}px`;
c.style.display = "inline-flex";
c.style.justifyContent = "center";
});
// 位相は文字の横位置から決める。右の文字ほど遅れて波打つ
const left = heading.getBoundingClientRect().left;
const xs = chars.map((c) => c.getBoundingClientRect().left - left);
const wavelength = Math.max(...xs) * WAVE || 1;
const phase = xs.map((x) => x / wavelength);
if (reduceMotion) {
chars.forEach((c) => (c.style.fontWeight = "700"));
return;
}
const tick = (time) => {
chars.forEach((c, i) => {
const s = 0.5 - 0.5 * Math.cos(Math.PI * 2 * (time / PERIOD - phase[i]));
c.style.fontWeight = String(Math.round(LIGHT + (HEAVY - LIGHT) * s));
});
};
gsap.ticker.add(tick);
// 5 秒より長く動き続けるので、止める手段を置く
let playing = true;
toggle.hidden = false;
toggle.addEventListener("click", () => {
playing = !playing;
toggle.setAttribute("aria-pressed", String(!playing));
if (playing) gsap.ticker.add(tick);
else gsap.ticker.remove(tick);
});
});
});