デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
紙に見立てた面から大きな文字の形を mask でくり抜き、その下に景色を敷いています。スクロールに合わせて、くり抜いた文字だけを拡大していくと、穴が広がって景色が画面いっぱいに現れます。拡大しているのは文字の形そのものなので、広がっていく穴の縁は最後まで字の輪郭のままです。
倍率は、スクロールの進み具合を掛け算の指数にして増やします。倍率をまっすぐ増やすと、近づくほど遅くなったように見えるためです。景色のほうは 1.3 倍から等倍へ引いていき、迫ってくるのではなく奥へ入っていく感じを足しています。
くぐり抜ける縦棒の中心と太さ、画面を覆いきるまでの倍率は、実際の字を描いて測って決めます。字の中ほどの 1 行を描いて、塗られている幅を数えるので、字の幅と縦画の太さがまったく違う和文の字に替えても使えます。測るのは fonts.ready を待ってからにしてください。読み込みの前に測ると、代わりの書体の幅で決まってしまい、縦棒から外れた場所へ向かって迫ります。
固定は CSS の sticky でも ScrollTrigger の pin でも作れますが、どちらでも、固定する区間の高さは抜けきるまでのスクロール量と同じだけ取ります。ここでは画面の 1.6 倍で、短くすると抜けきる前に固定が外れ、文字が途中の大きさのまま流れていきます。覆いきったところで紙の面は消して、拡大しきった字の縁が残らないようにします。抜けたあとに出る見出しは、行末の「。」を数えずに、その手前までの字の左右の端の中央を画面の中央にそろえ、「。」は右へぶら下げます。「。」まで含めて中央にすると、その分だけ字が左へ寄って見えるためです。
くぐらせる字は、「I」や「T」、和文なら「山」や「中」のように、まん中を縦に貫く画のある字を選ぶと穴がきれいに広がります。語を替えるときは、倍率を増やす時間と、次の見出しが出る時刻も一緒に見直してください。旅行・アウトドア・映画・ブランドのヒーローのように、景色そのものを見せたいページに向いています。
コード
<section class="mask-hero">
<div class="mask-hero__stage">
<!-- 穴からのぞく景色。写真でも動画でもよい -->
<img class="mask-hero__scene" src="/img/hero-valley.jpg" alt="" width="2560" height="1440">
<!-- 紙の面。mask の中の黒い文字が、そのまま穴になる -->
<svg class="mask-hero__page" viewBox="0 0 1280 720" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<mask id="mask-hero-cut" maskUnits="userSpaceOnUse" x="0" y="0" width="1280" height="720">
<rect width="1280" height="720" fill="#fff" stroke="none"></rect>
<text class="mask-hero__word" x="640" y="477" text-anchor="middle">WILD</text>
</mask>
<rect class="mask-hero__paper" width="1280" height="720" mask="url(#mask-hero-cut)"></rect>
</svg>
<div class="mask-hero__title">
<!-- 行ごとに span で包む。スクリプトが、行末の約物を除いた字の中央を行の中央にそろえる -->
<h1><span>さあ、山の奥へ。</span></h1>
<p>ここに説明文が入ります。</p>
</div>
</div>
</section>
.mask-hero {
--paper: #f2f1ed; /* 穴のあいた面の色 */
--ink: #ffffff; /* 景色の上に出る文字の色 */
--zoom: 160vh; /* 抜けきるまでのスクロール量 */
position: relative;
height: calc(100vh + var(--zoom));
background-color: var(--paper);
}
/* 固定する区間。JS が動かないときも、この固定だけは効く */
.mask-hero__stage {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
.mask-hero__scene,
.mask-hero__page {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.mask-hero__scene {
object-fit: cover;
transform: scale(1.3); /* 奥へ入っていく感じを出すため、少し寄せた所から始める */
}
.mask-hero__paper {
fill: var(--paper);
}
/* mask の中では、黒い所が穴になる。字の大きさは viewBox の単位(画面の高さの 46%) */
.mask-hero__word {
font-family: inherit;
font-size: 331px;
font-weight: 800;
fill: #000000;
}
.mask-hero__title {
position: absolute;
left: 0;
right: 0;
top: 18vh;
padding: 0 6vw;
text-align: center;
color: var(--ink);
}
.mask-hero__title h1 {
margin: 0;
font-size: clamp(36px, 5.6vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
/* inline の span には translate が効かないので inline-block にする */
.mask-hero__title h1 > span { display: inline-block; }
.mask-hero__title p {
max-width: 34em;
margin: 24px auto 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
}
/* 動きを減らす設定では、抜けきった絵で止める(JS も拡大を作らない) */
@media (prefers-reduced-motion: reduce) {
.mask-hero {
height: 100vh;
}
.mask-hero__page {
display: none;
}
.mask-hero__scene {
transform: none;
}
}
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const THROUGH = 1; // くぐり抜ける字の番号(0 始まり。WILD の I)
const COVER = 1.25; // 縦画が画面を覆いきる倍率の、さらに何倍まで拡大するか
const TRAILING = /[、。,.!?」』)]+$/u; // 見出しの行末でぶら下げる約物
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".mask-hero").forEach(async (hero) => {
// 字形が確定してから測る。読み込みの前に測ると、代わりの書体の幅で決まってしまう
await document.fonts.ready;
// 見出しの行末の約物(「、」「。」)は数えず、その手前までの字面の左右の端の中央を行の中央にそろえる。
// 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。ずれは em で持ち、字の大きさが変わっても合う
const ruler = document.createElement("canvas").getContext("2d");
hero.querySelectorAll(".mask-hero__title h1 > span").forEach((line) => {
const font = getComputedStyle(line);
Object.assign(ruler, { font: `${font.fontWeight} ${font.fontSize} ${font.fontFamily}`, letterSpacing: font.letterSpacing, textAlign: "left", lang: "ja" });
const { width } = ruler.measureText(line.textContent);
const ink = ruler.measureText(line.textContent.replace(TRAILING, ""));
line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(font.fontSize)}em`;
});
if (reduceMotion) return; // 抜けきった絵は CSS 側で作る
const word = hero.querySelector(".mask-hero__word");
const scene = hero.querySelector(".mask-hero__scene");
const page = hero.querySelector(".mask-hero__page");
const title = hero.querySelector(".mask-hero__title");
const style = getComputedStyle(word);
const view = word.ownerSVGElement.viewBox.baseVal;
const probe = document.createElement("canvas");
[probe.width, probe.height] = [view.width, view.height];
const g = probe.getContext("2d", { willReadFrequently: true });
g.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const text = word.textContent.trim();
const glyph = g.measureText(text[THROUGH]);
// text-anchor が middle なので、語の左端は中心から幅の半分だけ左になる
const [x, y] = [Number(word.getAttribute("x")), Number(word.getAttribute("y"))];
const left = x - g.measureText(text).width / 2;
let fx = Math.round(left + g.measureText(text.slice(0, THROUGH)).width + glyph.width / 2);
const fy = Math.round(y - (glyph.actualBoundingBoxAscent - glyph.actualBoundingBoxDescent) / 2);
// 実際に描き、字の高さの中ほどの行で、まん中の縦画が塗られている幅を測る(和文に替えても、字の幅ではなく縦画の太さが取れる)
g.fillText(text, left, y);
const row = g.getImageData(0, fy, view.width, 1).data;
const filled = (px) => row[px * 4 + 3] > 128;
let [a, b] = [fx, fx];
while (a > 0 && filled(a - 1)) a--;
while (b < view.width - 1 && filled(b + 1)) b++;
fx = (a + b) / 2;
const half = Math.max(2, (b - a + 1) / 2);
const max = (Math.max(fx, view.width - fx, fy * 0.6, (view.height - fy) * 0.6) / half) * COVER;
const state = { zoom: 0 };
// 倍率は「覆いきる倍率 ^ 進み」。まっすぐ増やすと、近づくほど遅くなったように見える
const render = () => {
const scale = max ** state.zoom;
word.setAttribute("transform", `translate(${fx} ${fy}) scale(${scale}) translate(${-fx} ${-fy})`);
};
gsap.set(title, { autoAlpha: 0, y: 30 });
gsap
.timeline({
scrollTrigger: { trigger: hero, start: "top top", end: "bottom bottom", scrub: true },
onUpdate: render,
})
.to(state, { zoom: 1, duration: 0.86, ease: "power1.in" }, 0)
.to(scene, { scale: 1, duration: 0.9, ease: "power2.out" }, 0)
.to(title, { autoAlpha: 1, y: 0, duration: 0.12 }, 0.74)
.to(page, { autoAlpha: 0, duration: 0.06 }, 0.84); // 覆いきったら、紙そのものを消す
});