デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
塗りつぶされた中央の行を挟んで、上下に同じ単語が輪郭だけで重なり、左右に揺れ続けます。揺れ始めるのは中央の行がいちばん早く、外側の行ほど遅れるので、真ん中から上下へ波が広がっていくように見えます。行の数は奇数にしてあり、塗りの行がちょうど真ん中に来て、波もそこから始まります。
左右の往復は、片道1.44秒の sine.inOut。stagger の from を center にして、隣の行とは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));
});
});