デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
同じ単語を奇数の行だけ重ね、行ごとに x 方向の往復を stagger の from: "center" でずらすと、中央の行から上下へ波が伝わるように見えます。中央だけ塗り、ほかの行は輪郭だけの文字です。
往復は 1.44 秒の sine.inOut で、隣の行とは 0.24 秒ずつずれます。この間隔を広げるほど、行の並びが大きく曲がって見えます。
輪郭の行は -webkit-text-stroke で線を描き、字の塗りを地の色にして線を先に見せます。可変フォントでは字の中に線の重なりが出ることがあるので、paint-order を stroke fill にして隠します。英大文字だけの語は、行の高さを字の大きさの 0.84 倍まで詰めても上下の行が触れません。和文の語を重ねるなら、画数の少ないかたかなの語にすると輪郭の線が字の中で込み合わずに読め、行の高さは字面が大きいぶん 0.96 倍ほどにとどめます。
動き続けるので、画面外に出たら止め、一時停止のボタンも添えています。単語の大きさは書体で幅が変わるため、両脇の余白を見ながら決めます。音楽イベントやスポーツなど、勢いのあるヒーローに向いています。
コード
<section class="kinetic">
<!-- 中央の行だけ読み上げの対象(h2)。ほかは同じ語を重ねた飾りなので aria-hidden -->
<div class="kinetic__stack">
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
<h2 class="kinetic__row kinetic__row--fill">RHYTHM</h2>
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
<div class="kinetic__row" aria-hidden="true">RHYTHM</div>
</div>
<!-- 5 秒より長く動き続けるものには、止める手段を置く。動かすときだけ JS が出す -->
<button class="kinetic__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</section>
.kinetic {
--bg: #111111;
--ink: #eeece6;
--amp: 16cqi; /* 揺れ幅(左右に同じだけ)。入れ物の中身の幅に対する比なので、画面が狭ければ揺れも小さくなる */
container-type: inline-size; /* cqi の基準をこの入れ物にする */
display: grid;
justify-items: center;
gap: 32px;
padding: 96px 6vw;
background: var(--bg);
color: var(--ink);
overflow: hidden;
text-align: center;
}
.kinetic__stack {
display: flex;
flex-direction: column;
align-items: center;
font-size: clamp(40px, 9vw, 96px);
font-weight: 800;
line-height: 0.84; /* 大文字だけなので詰めてよい(和文の語にするなら 0.96 ほど) */
letter-spacing: -0.02em;
white-space: nowrap;
}
/* 輪郭の行。字の塗りを地の色にして先に線を描く(可変フォントの重なり線を隠す) */
.kinetic__row {
margin: 0;
font: inherit;
color: var(--bg);
-webkit-text-stroke: 3px var(--ink);
paint-order: stroke fill;
translate: calc(var(--swing, 0) * var(--amp)) 0; /* --swing(-1〜1)は JS が動かす */
}
/* 中央の行だけ塗る。輪郭の行と太さをそろえるため、同じ線を残す */
.kinetic__row--fill {
color: var(--ink);
}
.kinetic__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;
}
.kinetic__toggle[aria-pressed="true"] {
background: var(--ink);
color: var(--bg);
}
.kinetic__toggle:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
import { gsap } from "gsap";
const HALF = 1.44; // 左端から右端まで(半周期)
const EACH = 0.24; // 隣の行とのずれ。大きいほど行の並びが大きく曲がる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".kinetic").forEach((section) => {
const rows = section.querySelectorAll(".kinetic__row");
// 動きを減らす設定では静止したまま置く(止める手段も出さない)
if (reduceMotion) return;
// 動かすのは -1〜1 の割合だけ。揺れ幅は CSS の --amp(入れ物の幅に対する比)が決めるので、狭い画面でも行が外へ切れない
const wave = gsap.fromTo(
rows,
{ "--swing": -1 },
{
"--swing": 1,
duration: HALF,
ease: "sine.inOut",
stagger: { each: EACH, from: "center", repeat: -1, yoyo: true },
}
);
// 画面外では動かし続けない。ただし自分で止めた人には従う
let userPaused = false;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (userPaused) continue;
entry.isIntersecting ? wave.play() : wave.pause();
}
},
{ threshold: 0 }
);
io.observe(section);
const toggle = section.querySelector(".kinetic__toggle");
toggle.hidden = false;
toggle.addEventListener("click", () => {
userPaused = !userPaused;
wave.paused(userPaused);
toggle.setAttribute("aria-pressed", String(userPaused));
});
});