本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ヒーロー

ヒーローの巨大な文字の穴から景色が見え、スクロールで画面いっぱいに抜ける

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
ヒーロー
動作
DOM
種類
レシピ
使うもの
ScrollTrigger
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

紙に見立てた面から大きな文字の形を mask でくり抜き、その下に景色を敷いています。スクロールに合わせて、くり抜いた文字だけを拡大していくと、穴が広がって景色が画面いっぱいに現れます。拡大しているのは文字の形そのものなので、広がっていく穴の縁は最後まで字の輪郭のままです。

倍率は、スクロールの進み具合を掛け算の指数にして増やします。倍率をまっすぐ増やすと、近づくほど遅くなったように見えるためです。景色のほうは 1.3 倍から等倍へ引いていき、迫ってくるのではなく奥へ入っていく感じを足しています。

くぐり抜ける縦棒の中心と太さ、画面を覆いきるまでの倍率は、実際の字を描いて測って決めます。字の中ほどの 1 行を描いて、塗られている幅を数えるので、字の幅と縦画の太さがまったく違う和文の字に替えても使えます。測るのは fonts.ready を待ってからにしてください。読み込みの前に測ると、代わりの書体の幅で決まってしまい、縦棒から外れた場所へ向かって迫ります。

固定は CSS の sticky でも ScrollTrigger の pin でも作れますが、どちらでも、固定する区間の高さは抜けきるまでのスクロール量と同じだけ取ります。ここでは画面の 1.6 倍で、短くすると抜けきる前に固定が外れ、文字が途中の大きさのまま流れていきます。覆いきったところで紙の面は消して、拡大しきった字の縁が残らないようにします。抜けたあとに出る見出しは、行末の「。」を数えずに、その手前までの字の左右の端の中央を画面の中央にそろえ、「。」は右へぶら下げます。「。」まで含めて中央にすると、その分だけ字が左へ寄って見えるためです。

くぐらせる字は、「I」や「T」、和文なら「山」や「中」のように、まん中を縦に貫く画のある字を選ぶと穴がきれいに広がります。語を替えるときは、倍率を増やす時間と、次の見出しが出る時刻も一緒に見直してください。旅行・アウトドア・映画・ブランドのヒーローのように、景色そのものを見せたいページに向いています。

コード

HTML
<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>

CSS
.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;
  }
}

JavaScript
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); // 覆いきったら、紙そのものを消す
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。