デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- SplitText
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
散らばった文字が、回りながら集まって見出しに組み上がります。見出しを1文字ずつに分け、散らばった位置・角度・大きさ・濃さをあらかじめ決めてから、元の位置へ戻すトゥイーンとして書いています。散らばる先は画面を格子に割って1文字1マスずつ振り分け、マスの中で少しだけずらしています。ただの乱数で位置を決めると文字どうしが固まり、空いた場所ができてしまうためです。
集まる時間は1.6秒、power3.inOutで勢いよく寄ってきて静かに収まります。文字ごとの遅れは、順番を混ぜてから均等に配っているので、決まった並びではなく思い思いに集まって見えます。奥行きを出すため、小さく散った文字ほど薄くしています。
薄くしすぎると、明るい色の文字が暗い地と混ざってくすんで見えます。濃さは半分より下げないようにしています。文字を動かす指定は、回転や拡大とあわせても画面が乱れないよう、2D の変形だけで組んでいます。
つまみは散らばる範囲・マスの中のずれ幅・回転の最大角・時間です。範囲を広げるほど、組み上がる瞬間の勢いが強く見えます。ブランドサイトのヒーローや、スライドの切り替えなど、画面全体を使って印象づけたい場面に向いています。
コード
<section class="scatter-hero">
<h2 class="scatter-hero__title">ばらばらの想いを、<br>ひとつに。</h2>
<p class="scatter-hero__lead">ここに説明文が入ります。</p>
</section>
.scatter-hero {
display: grid;
align-content: center;
justify-items: center;
gap: 20px;
min-height: 70vh;
padding: 96px 6vw;
overflow: clip;
background: #111111;
color: #eeece6;
text-align: center;
}
.scatter-hero__title {
margin: 0;
font-size: clamp(30px, 9vw, 96px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.scatter-hero__lead {
max-width: 34em;
margin: 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: keep-all; /* 和文は <wbr> を書いた文節の切れ目でだけ折り返す */
color: #a8a6a0;
}
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 字形が確定してから分割する(あとでフォントが差し替わると文字位置がずれる)
document.fonts.ready.then(() => {
document.querySelectorAll(".scatter-hero").forEach((section) => {
const title = section.querySelector(".scatter-hero__title");
const lead = section.querySelector(".scatter-hero__lead");
const split = SplitText.create(title, { type: "chars" });
const r = gsap.utils.random;
const assemble = gsap
.timeline({ paused: true })
.from(split.chars, {
x: () => r(-260, 260),
y: () => r(-130, 130),
rotation: () => r(-140, 140),
scale: () => r(0.45, 1.15),
opacity: 0.5,
duration: 1.6,
ease: "power3.inOut",
stagger: { each: 0.04, from: "random" },
force3D: false,
})
.from(lead, { autoAlpha: 0, y: 24, duration: 0.8, ease: "power2.out" }, 0.9);
if (reduceMotion) {
assemble.progress(1);
return;
}
// 画面に入ったら 1 回だけ組み上げる
const io = new IntersectionObserver(
(entries, self) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
self.unobserve(entry.target);
assemble.play();
}
},
{ rootMargin: "0px 0px -15%" }
);
io.observe(section);
});
});