デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger・SplitText
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
大きな文字が、スクロールに合わせて上の行から順に、左から右へ塗られていきます。スクロールを止めると、塗りは行の途中でもそのまま止まります。途中で止められるのは、文字ごとに色を変えるのではなく、行ごとに背景を伸ばす方式にしたからです。
SplitText で文章を行に分け、幅を文字に合わせた各行に、塗る色のグラデーションを背景として描いて、background-clip の text で文字の形に切り抜きます。その背景の幅を0%から100%へ、イージングなしで行ごとに順に伸ばします。範囲は、文章の上端が画面の高さの80%に来たときから下端が45%に来たときまでで、ScrollTrigger でスクロール量にそのまま連動させています(scrub)。なお background の一括指定は切り抜きの指定を初期値に戻すので、切り抜きは一括指定より後に書いています。
和文には語を区切る空白がないので、そのままでは文章全体がひとかたまりの行になります。文節の切れ目に <br> を入れて行を決めました。改行の文字や white-space の指定に頼ると、ひとまとまりのままです。狭い画面では <br> で区切った行がさらに折り返すので、Intl.Segmenter で文節の頭に幅のない空白を挟み、折り返したあとの見た目の行ごとに塗られるようにしています。行の幅は字形で決まるので、行に分けるのは書体の読み込みを待ってからにしています。
コード
<section class="fill-section">
<p class="fill-eyebrow">Approach</p>
<!--
和文は空白で語に分かれないので、文節の切れ目に <br> を入れて行を決める。
SplitText は <br> で行を分ける(英文なら <br> は要らず、幅で折り返した行に分かれる)。
-->
<p class="fill-text">気づかないほど自然に、<br>
でも、たしかに伝わる。<br>
そんな動きを、ひとつずつ<br>
ていねいにつくっています。</p>
</section>
.fill-section {
--dim: #343433; /* 塗る前の文字の色(背景色に文字の色を少し混ぜた暗いグレー) */
--fill: #eeece6; /* 塗った後の文字の色 */
padding: 40vh 6vw 22vh;
background-color: #111111;
}
.fill-eyebrow {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #a8a6a0;
}
/* JS が動かないときは、ふつうの読める文字のまま(切り抜きは分割した後にだけ効かせる) */
.fill-text {
margin: 0;
font-size: clamp(28px, 4.4vw, 56px);
font-weight: 700;
line-height: 1.5;
letter-spacing: 0.04em;
color: var(--fill);
}
/* SplitText が作る行。塗る範囲を文字の幅にそろえ、背景を文字の形で切り抜く */
.fill-text.is-split .line {
width: fit-content;
color: transparent;
background: linear-gradient(var(--fill), var(--fill)) 0 0 / 0% 100% no-repeat, var(--dim);
-webkit-background-clip: text;
/* 一括指定(background)は切り抜きを初期値に戻すので、必ずその後に書く */
background-clip: text;
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(ScrollTrigger, SplitText);
const words = new Intl.Segmenter("ja", { granularity: "word" });
// 前の語に付ける区切り: 句読点・1 字の助詞・ひらがなで終わる語に続くひらがな(「デザイン|し|てい|ます」を 1 つにする)
const joins = (before, { segment, isWordLike }) =>
!isWordLike || /^[ぁ-ん]$/.test(segment) || (/^[ぁ-ん]+$/.test(segment) && /[ぁ-ん]$/.test(before));
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 行の幅は字形で決まるので、書体の読み込みが終わってから行に分ける
document.fonts.ready.then(() => {
document.querySelectorAll(".fill-text").forEach((text) => {
// 和文は空白で語に分かれないので、文節の頭に幅のない空白を挟んで分ける。
// 狭い画面で <br> の行がさらに折り返しても、見た目の行ごとに塗れる
const split = SplitText.create(text, {
type: "lines",
linesClass: "line",
prepareText: (t) => Array.from(words.segment(t)).reduce((out, s) => out + (out && !joins(out, s) ? "\u200b" : "") + s.segment, ""),
wordDelimiter: { delimiter: /\u200b/, replaceWith: "\u200b" },
});
text.classList.add("is-split"); // ここで初めて、文字の形で切り抜く見た目にする
// 動きを減らす設定では、塗り終わった状態で止めておく
if (reduceMotion) {
gsap.set(split.lines, { backgroundSize: "100% 100%" });
return;
}
gsap.fromTo(
split.lines,
{ backgroundSize: "0% 100%" },
{
backgroundSize: "100% 100%",
ease: "none",
duration: 1,
stagger: 1, // 1 行ずつ順に塗る
scrollTrigger: {
trigger: text,
start: "top 80%", // 文章の上端が画面の高さの 80% に来たら塗り始める
end: "bottom 45%", // 下端が 45% に来たら塗り終わる
scrub: true, // スクロール量にそのまま連動させる(数値の scrub は実時間で追うので使わない)
},
}
);
});
});