デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- 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 の間に空白を入れておいてください。入れないと行末と行頭の単語がつながって読まれます。タッチ端末では、この動きはつけずに止まった見出しのまま表示します。
コード
<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>
.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;
}
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));
});
});