デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
縦書きで組んだ見出しと本文を、スクロールに合わせて右の行から 1 行ずつ出します。行の塊は position: sticky で画面に留め、そのあいだのスクロール量を、行を出す進み具合にそのまま割り当てます(scrub)。行の中では、上から下へ縁のやわらかいマスクが降りていくので、字が読む順に上から現れます。
各行には、同じ字を 2 層重ねています。上の層はふつうの文字で、下の層は字を透明にして、ぼかした text-shadow だけで描いた「にじみ」です。にじみの層は、文字の層より少し先を細い帯のマスクで通るので、墨がにじんでから字がくっきり出てくるように見えます。どちらのマスクも 0〜1 の CSS 変数 --p ひとつから計算し、GSAP はこの変数だけを動かします。出きると帯は行の外へ抜けるので、にじみは残りません。
句読点と括弧は、縦書きにするだけで和文の書体の縦書き用の字形になります(、と。は字の右上、「」は向きを変えた形)。字形を切り替える書体の機能を、自分で指定する必要はありません。2 桁の数字は text-combine-upright で 1 字分に収めます(縦中横)。行の頭の「は、既定では前に半字の空きが残って下がって見えるので、text-spacing-trim を trim-start にして行の頭をそろえます。行は自分で分け、文節の切れ目で改行します。
行は、同じ段落の次の行を 0.7、段落の頭の行を 1.3 の間隔で並べ、1 行を 1.2 の長さで出します(どれもスクロール量に対する割合)。行どうしが重なりながら出るので、右の行が出きる前に左の行が始まり、読む速さに合わせて文が流れます。塊を留めておく間は、節の高さを画面の 3 倍にして作ります。幅 680px 以下では縦の列が収まらないので横書きに切り替え、同じマスクを左から右へ向けて使います。
横書きの行を窓の下からせり上げる出し方や、行を左から塗っていく出し方と違い、縦書きの行を右から左へ、字の並ぶ向きに沿って出していきます。つまみは、マスクの縁のやわらかさ(行の長さの 30%)、にじみの影のぼかしと濃さ、行の間隔、節の高さです。動きを減らす設定では、画面に留めずに最初から出ている文として見せます。会社の思いや代表のあいさつ、随筆、和の商品の紹介に向いています。
コード
<!-- 縦書きの 1 行 = .vcopy__line。行は自分で分ける(和文は文節の切れ目で)。2 桁の数字は .vcopy__tcy で縦中横にする -->
<section class="vcopy" aria-labelledby="vcopy-title">
<div class="vcopy__stage">
<div class="vcopy__text">
<p class="vcopy__eyebrow">Philosophy</p>
<h2 class="vcopy__title" id="vcopy-title">
<span class="vcopy__line">ちいさな仕事を、</span>
<span class="vcopy__line">ていねいに重ねる。</span>
</h2>
<p class="vcopy__body">
<span class="vcopy__line">私たちは、町の小さな工房から</span>
<span class="vcopy__line">始まりました。創業から<span class="vcopy__tcy">20</span>年、</span>
<span class="vcopy__line">看板も広告もほとんど出さずに、</span>
<span class="vcopy__line">紹介と口伝えだけで続いてきました。</span>
<span class="vcopy__line">「いい仕事は、次の仕事を呼ぶ」と</span>
<span class="vcopy__line">先代はよく話していました。</span>
<span class="vcopy__line">目の前の一人に、きちんと応える。</span>
<span class="vcopy__line">これからも、急がず、手を抜かずに。</span>
</p>
<p class="vcopy__sign"><span class="vcopy__line">代表 木村 直樹</span></p>
</div>
</div>
</section>
.vcopy {
--ink: #262626;
--mute-small: #6b6b6b;
--bleed: rgba(38, 38, 38, 0.55); /* にじみの層の墨(文字の色をぼかした影) */
--dir: 180deg; /* 行を出す向き(縦書きは上から下) */
height: 300vh; /* このスクロールの間、行の塊を画面に留めて出していく */
background: #f2f1ed;
color: var(--ink);
}
.vcopy__stage {
position: sticky;
top: 0;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: clip;
}
/* 縦書き。句読点と括弧は、和文の書体の縦書き用の字形になる。行頭の「の前の半字の空きは詰める */
.vcopy__text {
writing-mode: vertical-rl;
text-orientation: mixed;
height: 420px;
white-space: nowrap;
text-spacing-trim: trim-start;
}
.vcopy__eyebrow { margin: 0 0 0 40px; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; color: var(--mute-small); }
.vcopy__title { margin: 0 0 0 56px; font-size: 40px; font-weight: 700; line-height: 1.5; letter-spacing: 0.08em; }
.vcopy__body { margin: 0 0 0 40px; font-size: 20px; line-height: 2.1; letter-spacing: 0.12em; }
.vcopy__sign { margin: 0; font-size: 14px; line-height: 2; letter-spacing: 0.12em; text-align: end; color: var(--mute-small); }
.vcopy__tcy { text-combine-upright: all; }
/* 1 行 = 文字の層とにじみの層。どちらも --p(0〜1)から、縁のやわらかいマスクで描く */
.vcopy__line { --p: 1; position: relative; display: block; }
.vcopy__ink {
display: block;
-webkit-mask-image: linear-gradient(var(--dir), #000 calc(var(--p) * 130% - 30%), transparent calc(var(--p) * 130%));
mask-image: linear-gradient(var(--dir), #000 calc(var(--p) * 130% - 30%), transparent calc(var(--p) * 130%));
}
/* にじみは、文字の層の少し先を帯で通る(出きると帯が行の外へ抜けて消える) */
.vcopy__blur {
position: absolute;
inset: 0;
color: transparent;
text-shadow: 0 0 5px var(--bleed);
-webkit-mask-image: linear-gradient(var(--dir), transparent calc(var(--p) * 150% - 50%), #000 calc(var(--p) * 150% - 20%), transparent calc(var(--p) * 150%));
mask-image: linear-gradient(var(--dir), transparent calc(var(--p) * 150% - 50%), #000 calc(var(--p) * 150% - 20%), transparent calc(var(--p) * 150%));
}
/* 狭い画面では縦書きの列が収まらないので、横書きにして左から右へ出す */
@media (max-width: 680px) {
.vcopy { --dir: 90deg; }
.vcopy__text { writing-mode: horizontal-tb; height: auto; padding: 0 16px; }
.vcopy__eyebrow { margin: 0 0 16px; }
.vcopy__title { margin: 0 0 24px; font-size: 28px; }
.vcopy__body { margin: 0 0 24px; font-size: 16px; line-height: 1.9; letter-spacing: 0.06em; }
.vcopy__sign { letter-spacing: 0.06em; }
}
/* 動きを減らす設定では、スクロールで留めずに、最初から出ている文として見せる */
@media (prefers-reduced-motion: reduce) {
.vcopy { height: auto; }
.vcopy__stage { position: static; height: auto; padding: 96px 0; }
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const STEP = 0.7; // 同じ段落の次の行までの間
const BREAK = 1.3; // 段落の頭の行までの間(見出し → 本文 → 署名のあいだに少し間を置く)
const LINE_TIME = 1.2; // 1 行を出しきるまでの長さ(どれもスクロール量に対する割合)
document.querySelectorAll(".vcopy").forEach((section) => {
const lines = [...section.querySelectorAll(".vcopy__line")];
// 行の中身を、文字の層と、同じ字をぼかした影で描くにじみの層に分ける(にじみの層は読み上げない)
lines.forEach((line) => {
const ink = document.createElement("span");
ink.className = "vcopy__ink";
ink.append(...line.childNodes);
const bleed = ink.cloneNode(true);
bleed.className = "vcopy__blur";
bleed.setAttribute("aria-hidden", "true");
line.append(ink, bleed);
});
// 動きを減らす設定では、最初から出ている(--p は CSS の既定の 1)
if (reduceMotion) return;
// 右の行(横書きでは上の行)から順に。塊が画面に留まっているあいだのスクロール量に、そのまま割り当てる
const tl = gsap.timeline({
scrollTrigger: { trigger: section, start: "top top", end: "bottom bottom", scrub: true },
});
let at = -STEP;
lines.forEach((line) => {
at += !line.previousElementSibling && at > 0 ? BREAK : STEP;
tl.fromTo(line, { "--p": 0 }, { "--p": 1, duration: LINE_TIME, ease: "none" }, at);
});
});