デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
太陽と雲・遠くの山・見出し・中ほどの丘・手前の丘・前景の森を、SVG の層にして重ねます。各層は、カーソルの画面の中心からのずれに、層ごとの深さを掛けた分だけ、逆向きに動かします。手前の層ほど深さを大きくし、少し遅れてついてくるようにすると、奥行きが出ます。
見出しは山と丘のあいだの層に挟み、手前の丘で文字の下のほうを隠します。黒の幕(左 0.6 から右 0.3 へ薄くなる)は、見出しの層のすぐ奥に置いて動かさず、空・太陽・遠くの山だけを暗くします。見出しより手前にかけると、白い見出しまで灰色に沈みます。
層は画面より少し大きく作り、ずれても端が見えないようにします。文字を含む層には will-change を付けません。専用のレイヤーに載せると、文字の描かれ方が変わることがあります。
実際のサイトでは、pointermove でカーソルの位置を quickTo に渡し、カーソルが外れたら 0 に戻します。追従にかける時間は層ごとに変え、手前の層ほど遅れてついてきます。タッチ端末と、動きを減らす設定のときは、つなぎません。
層ごとの深さと遅れ、縦のずれの割合、画面の外へ広げる量、色で調整します。1 枚のカードを 3D で傾ける演出や、スクロールで列が流れる演出と違い、カーソルで多層の風景がずれます。旅行・アウトドア・ゲーム・キッズ向けのヒーローに向いています。
コード
<section class="hero" aria-labelledby="hero-title">
<!-- 層は奥から手前の順に置く。data-depth が大きいほど手前で、よく動く(ポインタと逆へ) -->
<img class="hero__layer" src="/img/hero-sky.jpg" alt="" data-depth="0.012" data-lag="0.08">
<img class="hero__layer" src="/img/hero-far.png" alt="" data-depth="0.026" data-lag="0.1">
<!-- 幕は見出しのすぐ奥。ここより手前に置くと、白い見出しまで灰色に沈む -->
<span class="hero__scrim" aria-hidden="true"></span>
<h1 class="hero__title" id="hero-title" data-depth="0.042" data-lag="0.12">WANDER</h1>
<!-- 見出しより手前の層。下のほうを隠して、見出しが山と丘の間に挟まって見える -->
<img class="hero__layer" src="/img/hero-mid.png" alt="" data-depth="0.064" data-lag="0.15">
<img class="hero__layer" src="/img/hero-near.png" alt="" data-depth="0.092" data-lag="0.18">
<img class="hero__layer" src="/img/hero-front.png" alt="" data-depth="0.13" data-lag="0.22">
<p class="hero__copy">静かな山道、あたたかな山小屋、のんびりした朝。</p>
</section>
.hero {
/* 層を画面の外へ広げる量。いちばん手前の層のずれ(depth × 画面の半分)より大きくする */
--overscan: 8%;
position: relative;
height: 100svh;
overflow: hidden;
background: linear-gradient(180deg, #b9a8ff 0%, #e7b8f0 34%, #ffc9d4 58%, #ffe2c6 76%);
color: #ffffff;
}
/* 画面より大きく置く。ずれても端が見えない */
.hero__layer {
position: absolute;
inset: calc(var(--overscan) * -1);
object-fit: cover;
pointer-events: none;
}
.hero__scrim {
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 100%);
pointer-events: none;
}
.hero__title {
position: absolute;
left: 0;
right: 0;
top: 27%;
margin: 0;
text-align: center;
font-size: clamp(64px, 17vw, 216px);
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
/* 文字を含む層には will-change を付けない。専用のレイヤーに載せると、文字の描かれ方が変わる */
}
.hero__copy {
position: absolute;
left: 6%;
bottom: 8%;
max-width: 28em;
margin: 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
}
import { gsap } from "gsap";
const VERTICAL = 0.5; // 縦のずれを横に対してどれだけにするか
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// タッチ端末ではポインタが無く、動きを減らす設定の人には負担になるので、どちらでもつながない
if (canHover && !reduceMotion) {
document.querySelectorAll(".hero").forEach((hero) => {
const movers = [...hero.querySelectorAll("[data-depth]")].map((node) => {
const depth = parseFloat(node.dataset.depth);
const lag = parseFloat(node.dataset.lag) || 0;
// 手前の層ほど遅れてついてくると、奥行きがはっきりする
const duration = 0.5 + lag * 4;
return {
depth,
x: gsap.quickTo(node, "x", { duration, ease: "power3.out" }),
y: gsap.quickTo(node, "y", { duration, ease: "power3.out" }),
};
});
hero.addEventListener("pointermove", (event) => {
const rect = hero.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
for (const mover of movers) {
// ポインタと逆へ動かす。中心からの距離に比例させるので、端ほど大きくずれる
mover.x(-dx * mover.depth);
mover.y(-dy * mover.depth * VERTICAL);
}
});
hero.addEventListener("pointerleave", () => {
for (const mover of movers) {
mover.x(0);
mover.y(0);
}
});
});
}