デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
紙に見立てた面から大きな文字の形を mask でくり抜き、その下に景色を敷いています。スクロールに合わせて、くり抜いた文字だけを拡大していくと、穴が広がって景色が画面いっぱいに現れます。拡大しているのは文字の形そのものなので、広がっていく穴の縁は最後まで字の輪郭のままです。
倍率は、スクロールの進み具合を掛け算の指数にして増やします。倍率をまっすぐ増やすと、近づくほど遅くなったように見えるためです。景色のほうは 1.3 倍から等倍へ引いていき、迫ってくるのではなく奥へ入っていく感じを足しています。
くぐり抜ける縦棒の中心と、画面を覆いきるまでの倍率は、実際の字形を測って決めます。測るのは fonts.ready を待ってからにしてください。読み込みの前に測ると、代わりの書体の幅で決まってしまい、縦棒から外れた場所へ向かって迫ります。
固定は CSS の sticky でも ScrollTrigger の pin でも作れますが、どちらでも、固定する区間の高さは抜けきるまでのスクロール量と同じだけ取ります。ここでは画面の 1.6 倍で、短くすると抜けきる前に固定が外れ、文字が途中の大きさのまま流れていきます。覆いきったところで紙の面は消して、拡大しきった字の縁が残らないようにします。
くぐらせる字は、縦棒のある字を選ぶと穴がきれいに広がります。語を替えるときは、倍率を増やす時間と、次の見出しが出る時刻も一緒に見直してください。旅行・アウトドア・映画・ブランドのヒーローのように、景色そのものを見せたいページに向いています。
コード
<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="430" 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">
<h1>Into the wild.</h1>
<p>ガイド付きの山歩き、山小屋、満天の星空。6つの山域をめぐる40のルートを紹介しています。</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;
}
.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 reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".mask-hero").forEach(async (hero) => {
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");
// 字形が確定してから測る。読み込みの前に測ると、代わりの書体の幅で決まってしまう
await document.fonts.ready;
const style = getComputedStyle(word);
const probe = document.createElement("canvas").getContext("2d");
probe.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
const text = word.textContent.trim();
const glyph = probe.measureText(text[THROUGH]);
// text-anchor が middle なので、語の左端は中心から幅の半分だけ左になる
const left = Number(word.getAttribute("x")) - probe.measureText(text).width / 2;
const fx = left + probe.measureText(text.slice(0, THROUGH)).width + glyph.width / 2;
const fy = Number(word.getAttribute("y")) - glyph.actualBoundingBoxAscent / 2;
// 字形の左右の縁のあいだが、そのまま縦棒の太さになる
const half = (glyph.actualBoundingBoxLeft + glyph.actualBoundingBoxRight) / 2;
const view = word.ownerSVGElement.viewBox.baseVal;
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); // 覆いきったら、紙そのものを消す
});