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

ヒーロー

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

デモ

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

仕様

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

しくみと調整

ポインタのいちばん近くにある文字が浮き上がり、ブラックからローズカラーへ色を変えて、影を落とします。周りの文字もなだらかな丘のように少しずつ持ち上がり、ポインタが通り過ぎたあとは、なぞった跡がなめらかに引いていきます。

持ち上がりの量は、ポインタと各文字の中心との距離から、ガウス関数で求めています。いちばん近い文字が上がるのは字の大きさの0.3倍。丘の広がりは横を0.62倍、縦を0.42倍として、縦は行の間隔より小さくしました。こうしておくとポインタのある行だけが持ち上がり、上下の行は揺れません。坂の途中の文字は丘の外へ向かって最大6°ほど傾き、持ち上がりも傾きも quickTo が0.4秒かけて追いかけます。

文字の中心は、offsetLeft と offsetTop で見出しの左上からの距離として持っています。getBoundingClientRect で測ると持ち上げて傾けたあとの位置が返るので、画面の幅が変わって測り直したときに丘がずれます。分割も書体の読み込みを待ってからで、仮の書体の幅で測ると、差し替わった字と中心が合わなくなります。

行は、行末の「、」や「。」を数えずに字面の両端の中央でそろえ、約物は右へぶら下げました。約物まで含めて中央にそろえると、その空きの分だけ字が左へ寄って見えます。文字ごとに分けるとカーニングも効かなくなるので、行の幅を測るときはカーニングを切り、表示と同じ条件にしています。

分けた文字は読み上げから外し、見出しには元の文を aria-label で付けているので、スクリーンリーダーには一度で読まれます。欧文の見出しを行ごとの span に分けるなら、span の間に空白を入れておいてください。入れないと行末と行頭の単語がつながって読まれます。タッチ端末では、この動きはつけずに止まった見出しのまま表示します。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。