デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しを前半と後半の 2 つに分け、スクロールに合わせて、左右の画面の外から中央へ寄せます。見出しが画面の中央に着いたところで、ちょうど一文になります。前半を文字の色、後半を補足の色にして明るさを変えると、寄ってくる 2 つを見分けやすくなります。
寄る前のずれは画面の幅の 0.32 倍で、0 まで power2.out で動かします。始めは速く、一文になる直前でゆっくり寄るので、ぴたりと合わさる感じが出ます。範囲は、見出しの上端が画面の高さの 76% に来たときから、見出しの中心が画面の中心に来たときまでで、スクロール量にそのまま結びます(scrub)。寄りきる位置は 0 ではありません。行末の「。」を数えずに、その手前までの字の左右の端の中央が画面の中央に来る所まで寄せ、「。」は右へぶら下げます。「。」まで含めて中央にすると、その分だけ字が左へ寄って見えるためです。字の端は canvas で字形を測って出し、画面の幅が変わるたびに測り直します。
ずれは画面の幅から決めるので、invalidateOnRefresh を付けて、画面の大きさが変わったら測り直します。寄る前の見出しは画面の外まではみ出すので、節に overflow-x: clip を付けて横のスクロールを出さないようにします。hidden にすると節がスクロールの入れ物になり、中の sticky などが効かなくなります。前半と後半の span は、inline のままだと transform が効かないので inline-block にします。
狭い画面では、前半と後半が 2 行に分かれ、それぞれが左右から寄ってきます。動きを減らす設定では、そろった見出しのまま見せます。つまみは、寄る前のずれ、ease、始まりと終わりの位置です。見出しが大きく迫る形や、文字が塗られていく形と違い、割れた言葉が 1 つになることで一文の意味を強めます。章の扉、コンセプトの一文、ブランドの約束を見せる節に向いています。
コード
<section class="split-section" aria-labelledby="split-title">
<p class="split-section__eyebrow">Craft</p>
<p class="split-section__lead">ここに説明文が入ります。</p>
<!-- 見出しを前半と後半の 2 つに分ける。スクリプトが左右の画面の外から寄せて、一文にする。前半の終わりの読点が字の半分ほど空くので、span のあいだに空白を入れない -->
<h2 class="split-headline" id="split-title">
<span class="split-headline__half is-left">ひとつずつ、</span><span class="split-headline__half is-right">心を込めて。</span>
</h2>
<p class="split-section__body">ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。ここに本文が入ります。</p>
</section>
.split-section {
--bg: #f2f1ed;
--ink: #262626;
--second: #898989; /* 後半の色。前半と明るさを変えると、寄ってくる 2 つを見分けやすい */
--small: #6b6b6b;
padding: 96px clamp(24px, 7vw, 96px) 30vh;
/* 寄る前の見出しは画面の外まではみ出す。横のスクロールを出さないよう、節の外を切る */
overflow-x: clip;
background-color: var(--bg);
color: var(--ink);
}
.split-section__eyebrow {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--small);
}
.split-section__lead,
.split-section__body {
margin: 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: auto-phrase; /* 和文を語の途中で折り返さず、文節の切れ目で折り返す */
}
/* 和文は 1 字がほぼ 1em の幅。12 字が節の幅に 1 行で収まる大きさにし、狭い画面では前半と後半が 2 行に分かれる */
.split-headline {
position: relative; /* 前半と後半の位置(offsetLeft)を、見出しの左端から測るため */
margin: 45vh 0 0;
text-align: center;
font-size: clamp(40px, 6.8vw, 96px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
/* inline の span には transform が効かないので inline-block にする */
.split-headline__half { display: inline-block; white-space: nowrap; }
.split-headline .is-right { color: var(--second); }
.split-section__body { margin-top: 48px; text-align: center; }
@media (max-width: 600px) {
.split-section__lead,
.split-section__body { font-size: 16px; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const OFFSET = 0.32; // 寄る前のずれ(画面の幅に対する比)
const TRAILING = /[、。,.!?」』)]+$/u; // 行末でぶら下げる約物
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 字の幅を測るので、書体が読み込まれてから組む
document.fonts.ready.then(() => {
document.querySelectorAll(".split-headline").forEach((headline) => {
const halves = headline.querySelectorAll(".split-headline__half");
const ruler = document.createElement("canvas").getContext("2d");
// 寄りきったときに、行末の約物を除いた字面の中央が見出しの中央に来るよう、ずらす量(px)。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。
// 1 行に並ぶときは前半の左端から後半の右端まで、2 行に分かれたときは行ごとに測る
const hang = (half) => {
const style = getComputedStyle(headline);
Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: "ja" });
const [first, last] = halves[0].offsetTop === halves[1].offsetTop ? halves : [half, half];
const inkLeft = first.offsetLeft - ruler.measureText(first.textContent).actualBoundingBoxLeft;
const inkRight = last.offsetLeft + ruler.measureText(last.textContent.replace(TRAILING, "")).actualBoundingBoxRight;
return headline.clientWidth / 2 - (inkLeft + inkRight) / 2;
};
// 動きを減らす設定では、一文にそろった見出しのまま見せる
if (reduceMotion) {
const place = () => halves.forEach((half) => gsap.set(half, { x: hang(half) }));
place();
window.addEventListener("resize", place);
return;
}
const shift = () => window.innerWidth * OFFSET;
gsap
.timeline({
scrollTrigger: {
trigger: headline,
start: "top 76%", // 見出しの上端が画面の高さの 76% に来たら寄り始める
end: "center center", // 見出しの中心が画面の中心に来たところで一文になる
scrub: true, // スクロール量にそのまま連動させる(数値の scrub は実時間で追うので使わない)
invalidateOnRefresh: true, // 画面の幅が変わったら、ずれと寄せる量を測り直す
},
})
.fromTo(halves[0], { x: () => hang(halves[0]) - shift() }, { x: () => hang(halves[0]), ease: "power2.out" }, 0)
.fromTo(halves[1], { x: () => hang(halves[1]) + shift() }, { x: () => hang(halves[1]), ease: "power2.out" }, 0);
});
});