デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- SplitText
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しと段落を SplitText で行に分け、行ごとに窓(はみ出しを隠す箱)を付けます。行を窓の高さだけ下へ沈めておき、ブロックが画面に入ったら、窓の下から 1 行ずつせり上げます。窓の外は見えないので、文字が行の下から湧いてくるように見えます。
せり上げは 0.9 秒の power4.out で、行は 0.08 秒ずつずらします。出だしが速く止まり際が長いので、行はすっと上がって静かに止まり、読み始めるまで待たせません。始めるのは、ブロックの上端が画面の上から 8 割の高さまで入ったときです。画面の下端で始めると、いちばん動く所が画面の外で終わってしまいます。
和文は空白で語に分かれないため、そのままでは段落全体がひとつの行になります。文や文節の切れ目に <br> を入れて、行を決めてください。改行の文字を white-space で活かすやり方では、行がまとまってしまいます。狭い画面で行がさらに折り返すときは、Intl.Segmenter で文節の頭に幅のない空白を挟んで語に分けておくと、見た目の行ごとに現れます。行の幅は字形で決まるので、分けるのは書体の読み込みが終わってからにします。
大きな英字の見出しは、窓を行の高さぴったりにすると、g や p の下が切れて別の字に読めます。和文の見出しでも、英字に差し替えたときに備えて同じ余白を足しておきます。窓の下に 0.2em の余白を足し、同じだけ負の余白で行の間隔を戻します。沈める量も、足した余白ごと窓の高さにします。
出すのは画面に入った 1 回だけです。スクロールの位置を追い続ける ScrollTrigger は使わず、画面に入ったことを知らせるブラウザの仕組み(IntersectionObserver)で足ります。動きを減らす設定では行に分けず、そのまま見せます。ずらす間隔を広げると、1 行ずつ語りかけるような間ができます。会社の考え方やサービスの説明など、読ませたい節の見出しとリード文に向いています。
コード
<section class="lines">
<h2 class="lines__title lines-reveal">動きには、<br>理由がある。</h2>
<!--
和文は空白で語に分かれないので、文や文節の切れ目に <br> を入れて行を決める。
SplitText は <br> で行を分ける(英文なら <br> は要らず、幅で折り返した行に分かれる)。
-->
<p class="lines__text lines-reveal">Webサイトの動きは、見た目を飾るだけでなく、<br>
いまどこにいて、次に何が起こるのかを<br>
伝える役目を持っています。<br>
だから、短く、わかりやすく。</p>
<p class="lines__text lines-reveal">私たちは、ひとつひとつの動きに<br>
理由を持たせてデザインしています。</p>
</section>
.lines {
--ink: #262626;
--ground: #f2f1ed;
--measure: 720px; /* 本文の幅 */
padding: 120px max(24px, (100% - var(--measure)) / 2);
background-color: var(--ground);
color: var(--ink);
}
.lines__title {
margin: 0 0 32px;
font-size: clamp(40px, 6vw, 64px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
text-wrap: balance;
}
.lines__text {
margin: 0 0 24px;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
}
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(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(() => {
// 動きを減らす設定では、分けずにそのまま見せる
if (reduceMotion) return;
document.querySelectorAll(".lines-reveal").forEach((block) => {
// mask: "lines" で、行ごとにはみ出しを隠す窓が付く。和文は文節の頭に幅のない空白を挟んで分け、
// 狭い画面で <br> の行がさらに折り返しても、見た目の行ごとに現れるようにする
const split = SplitText.create(block, {
type: "lines",
mask: "lines",
prepareText: (t) => Array.from(words.segment(t)).reduce((out, s) => out + (out && !joins(out, s) ? "\u200b" : "") + s.segment, ""),
wordDelimiter: { delimiter: /\u200b/, replaceWith: "\u200b" },
});
// 大きな見出しは、窓の下に余白を足して g・p・y の下を切らない(行の間隔は負の margin で保つ)
if (block.matches("h1, h2")) gsap.set(split.masks, { paddingBottom: "0.2em", marginBottom: "-0.2em" });
const rise = gsap.fromTo(
split.lines,
{ y: (i, line) => line.parentElement.offsetHeight }, // 窓の高さだけ沈めておく
{ y: 0, duration: 0.9, ease: "power4.out", stagger: 0.08, paused: true }
);
// ブロックの上端が、画面の下から 2 割の所まで入ったら 1 回だけ流す
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
rise.play();
},
{ rootMargin: "0px 0px -20%" }
);
io.observe(block);
// キーボードで中のリンクへ移ったときは、線まで入るのを待たずに出す
block.addEventListener("focusin", () => rise.play());
});
});