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

ヒーローの見出しでホバーした文字が持ち上がり、周りがなだらかに続く

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

見出しを SplitText で 1 文字ずつの箱に分け、各文字の中心を測っておきます。ポインタが動くたびに、文字の中心との距離からガウス関数で 0〜1 の持ち上がりを出し、近い文字ほど高く持ち上げ、色を変え、影を落とします。周りの文字は、なだらかな丘のように続きます。

いちばん近い文字は、字の大きさの 0.3 倍持ち上がります。丘の広がりは、横を字の大きさの 0.62 倍、縦を 0.42 倍にしています。縦を行の間隔より小さくしておくと、ポインタのいる行だけが持ち上がり、上下の行が一緒に揺れません。坂の途中の文字は丘の外へ向かって最大 6° ほど傾き、quickTo で 0.4 秒かけて追うので、なぞった跡がなめらかに引いていきます。

文字の中心は、offsetLeft と offsetTop から、見出しの左上からの距離として持ちます。getBoundingClientRect で測ると、持ち上げて傾けた後の位置が返るので、画面の幅が変わって測り直したときに丘がずれます。分けるのは、書体が読み込まれてからにします。仮の書体の幅で測ると、差し替わったときに中心が字からずれます。見出しの行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ、約物は右へぶら下げます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。1 文字ずつの箱に分けると字どうしのカーニングが効かなくなるので、測るときもカーニングを切ります。

分けた文字の箱は読み上げから外れ、見出しには元の文が aria-label で付くので、読み上げは 1 度で済みます。欧文の見出しを行ごとの span で分けるときは、span の間に空白を入れておきます。入れないと、読み上げの文で行末と行頭の語がつながります。指で触る端末と、動きを減らす設定ではつながず、動かない見出しのまま置きます。

持ち上がった文字は墨色からくすみローズへ移ります。色と影は CSS 変数を混ぜて作るので、色を変えるときは CSS だけを直します。持ち上がる高さと丘の横の広がりを大きくすると大胆に、小さくすると上品になります。クリエイティブスタジオやブランドサイト、キャンペーンの大きな見出しに向いています。

コード

HTML
<section class="lift-hero">
  <!-- 行ごとに span で分ける。欧文の見出しなら、span の間に空白を入れる(SplitText が付ける aria-label で、行末と行頭の語がつながるため) -->
  <h1 class="lift-hero__title"><span class="lift-hero__line">ことばで、</span><span class="lift-hero__line">心を動かす。</span></h1>
  <p class="lift-hero__lead">ここに説明文が入ります。</p>
  <div class="lift-hero__actions">
    <a class="lift-hero__button" href="/contact/">制作を相談する</a>
    <a class="lift-hero__button lift-hero__button--line" href="/works/">実績を見る</a>
  </div>
</section>

CSS
.lift-hero {
  --paper: #f2f1ed;
  --ink: #262626; /* ふだんの文字の色 */
  --hot: #b27771; /* いちばん持ち上がった文字の色(くすみローズ) */
  --lift-shadow: #262626; /* 持ち上がった文字の影 */
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 24px;
  min-height: 100vh;
  box-sizing: border-box;
  padding: 96px clamp(16px, 6vw, 96px);
  background: var(--paper);
  color: var(--ink);
  text-align: center;
}

.lift-hero__title {
  /* 文字の中心を、見出しの左上からの距離で測るため */
  position: relative;
  margin: 0;
  font-size: clamp(48px, 11vw, 144px);
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.02em;
}

.lift-hero__line { display: block; }

.lift-hero__lead {
  max-width: 30em;
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す */
}

.lift-hero__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 16px;
  margin-top: 8px;
}

.lift-hero__button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--ink);
  color: #ffffff;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.lift-hero__button--line {
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--ink);
  color: var(--ink);
}

.lift-hero__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

JavaScript
import { gsap } from "gsap";
import { SplitText } from "gsap/SplitText";

gsap.registerPlugin(SplitText);

const LIFT = 0.3; // いちばん近い文字が持ち上がる高さ(文字の大きさに対する比)
const SPREAD_X = 0.62; // 丘の横の広がり(同じく)。広げるほど、周りの文字がなだらかに続く
const SPREAD_Y = 0.42; // 丘の縦の広がり。行の間隔より小さくすると、ポインタのいる行だけが持ち上がる
const TILT = 10; // 坂の途中の文字の傾き(度)
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 字形が決まってから分ける。仮の書体の幅で分けると、書体が差し替わったときに文字の位置がずれる
document.fonts.ready.then(() => {
  document.querySelectorAll(".lift-hero").forEach((hero) => {
    const title = hero.querySelector(".lift-hero__title");
    // 行ごとに、行末の約物(「、」「。」)は数えず、その手前までの字面(インクの左右の端)の中央を見出しの中央にそろえる。
    // 約物は右へぶら下がる(含めて中央にすると、字が左へ寄って見える)。ずれは em で持ち、字の大きさが変わっても合うようにする
    const style = getComputedStyle(title), ruler = document.createElement("canvas").getContext("2d"), shifts = new Map();
    // 字は 1 字ずつの箱に分けるので、字どうしのカーニングは効かない。測るときもカーニングを切る
    Object.assign(ruler, { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", fontKerning: "none", lang: document.documentElement.lang || "ja" });
    title.querySelectorAll(".lift-hero__line").forEach((line) => {
      const { width } = ruler.measureText(line.textContent), ink = ruler.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
      shifts.set(line, (width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize));
      line.style.translate = `${shifts.get(line)}em`;
    });
    // 文字ごとの箱に分ける。見出しには元の文が aria-label で付き、分けた箱は読み上げから外れる
    const split = SplitText.create(title, { type: "words,chars" });
    // 指で触る端末と、動きを減らす設定ではつながず、動かない見出しのまま置く
    if (!canHover || reduceMotion) return;

    // 文字の中心を、見出しの左上からの距離で持つ。offsetLeft は transform に左右されないので、持ち上げたままでも測り直せる。
    // 行を translate でずらすと、行が文字の offsetParent になる。見出しまでたどって足し、行のずれも足す
    let size = 0;
    let centers = [];
    const measure = () => {
      size = parseFloat(getComputedStyle(title).fontSize);
      centers = split.chars.map((char) => {
        let x = char.offsetWidth / 2 + shifts.get(char.closest(".lift-hero__line")) * size, y = char.offsetHeight / 2;
        for (let node = char; node && node !== title; node = node.offsetParent) { x += node.offsetLeft; y += node.offsetTop; }
        return [x, y];
      });
    };
    measure();
    new ResizeObserver(measure).observe(title);

    // 文字ごとに、持ち上がり g(0〜1)と傾きを quickTo で追わせ、g が動くたびに描く
    const movers = split.chars.map((char) => {
      const state = { g: 0, tilt: 0 };
      const draw = () => {
        const lift = Math.round(size * LIFT * state.g);
        char.style.transform = `translateY(${-lift}px) rotate(${state.tilt}deg)`;
        // 色と影は CSS 変数を混ぜて作る(色を変えるときは CSS だけ直せばよい)
        char.style.color = `color-mix(in srgb, var(--hot) ${state.g * 100}%, var(--ink))`;
        char.style.textShadow = `0 ${lift * 0.5}px ${lift * 0.6}px color-mix(in srgb, var(--lift-shadow) ${state.g * 34}%, transparent)`;
      };
      return {
        g: gsap.quickTo(state, "g", { duration: 0.4, ease: "power3.out", onUpdate: draw }),
        tilt: gsap.quickTo(state, "tilt", { duration: 0.4, ease: "power3.out" }),
      };
    });

    // ポインタからの距離のガウスで、近い文字ほど高く持ち上げる。amount が 0 ならすべて元の高さへ
    const lift = (x, y, amount) => {
      const sx = size * SPREAD_X;
      const sy = size * SPREAD_Y;
      centers.forEach(([cx, cy], i) => {
        const dx = cx - x;
        const dy = cy - y;
        const g = amount * Math.exp(-(dx * dx) / (2 * sx * sx) - (dy * dy) / (2 * sy * sy));
        movers[i].tilt(TILT * (dx / sx) * g); // 丘の右の坂は右へ、左の坂は左へ傾く
        movers[i].g(g);
      });
    };
    hero.addEventListener("pointermove", (event) => {
      const box = title.getBoundingClientRect();
      lift(event.clientX - box.left, event.clientY - box.top, 1);
    });
    hero.addEventListener("pointerleave", () => lift(0, 0, 0));
  });
});

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

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