デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
見出しの横に置いたキャラクターの瞳を、カーソルの方へ向けます。キャラクターはフラットデザインで、顔・白目・瞳・頬の丸と、口の半円という平らな色面だけで組み、影やグラデーション、線の輪郭は使いません。瞳は目ごとに「目の中心からポインタへの向き」にずらすので、顔のすぐ近くでは左右の瞳が内側へ寄り、寄り目になります。ずらす量は「ポインタまでの距離 × 0.06」で、白目の半径から瞳の半径と余白 6 を引いた所で止めるので、瞳が白目の縁からはみ出しません。ヒーローの外へ出ると、瞳は中央へ戻ります。
瞳は quickTo で 0.35 秒遅れてついてきます。遅れが無いと機械のように見え、0.5 秒を超えると反応が鈍く感じられます。カーソルが顔の縁から 40px の内側に入ると、目を縦に 0.1 倍までつぶして戻すまばたきを 1 回します。閉じるのに 0.08 秒、閉じたまま 0.05 秒、開くのに 0.16 秒で、閉じるほうを速くするとまばたきらしく見えます。
まばたきは、顔の縁から 70px より外へ出るまで撃ち直しません。しきい値を 1 つにすると、縁の上でカーソルが揺れるたびにまばたきが続きます。輪郭の線が無い目は、つぶすだけだと白く細い筋が残り、閉じた目に見えません。そこで、閉じきる間際に白目の色を瞳の色へ移し、閉じた目を 1 本の濃い線にします。色は CSS の color-mix で白目と瞳の色を混ぜ、混ぜる割合の変数 --shut を GSAP で動かします。つぶすのと同じ進み方で混ぜると半分閉じた目が灰色に濁るので、閉じるときは expo.in、開くときは expo.out にして、濃い色が出るのを閉じきる前後だけにします。
キーボードで中のボタンへ移ると、瞳がその項目を見ます。指で触る端末ではつながず、正面を向いたままにします。動きを減らす設定では、遅れを無くしてすぐにカーソルの方を向かせ、まばたきはしません。
つまみは、ずらす割合、追いつく時間、まばたきを撃つ距離と閉じ方の時間、顔と頬の色(CSS の変数)です。風景の層をずらして奥行きを出す演出や、カーソルそのものの形を変える演出と違い、ページの中のキャラクターが読む人を見返すので、相談窓口やサポートのページ、子ども向けやペット関連のサービス、キャラクターのいるブランドのヒーローに向いています。
コード
<section class="gaze-hero" aria-labelledby="gaze-hero-title">
<div class="gaze-hero__copy">
<p class="gaze-hero__label">SUPPORT</p>
<h1 class="gaze-hero__title" id="gaze-hero-title">困ったときは、<br>まず聞いてください</h1>
<p class="gaze-hero__lead">平日9時〜18時は、担当者がチャットでお答えします。<br>夜間にいただいた質問は、翌朝までにお返しします。</p>
<a class="gaze-hero__button" href="/chat/">チャットで相談する</a>
</div>
<!-- 飾りのキャラクター。平らな丸と半円だけで組む。.eye__pupil(瞳)をカーソルの方へずらし、.eye ごと縦につぶしてまばたきする -->
<svg class="gaze-face" viewBox="0 0 320 320" aria-hidden="true" focusable="false">
<circle class="gaze-face__skin" cx="160" cy="160" r="150" />
<circle class="gaze-face__cheek" cx="64" cy="202" r="16" />
<circle class="gaze-face__cheek" cx="256" cy="202" r="16" />
<path class="gaze-face__mouth" d="M144 206A16 16 0 0 0 176 206Z" />
<g class="eye">
<circle class="eye__white" cx="106" cy="142" r="36" />
<circle class="eye__pupil" cx="106" cy="142" r="15" />
</g>
<g class="eye">
<circle class="eye__white" cx="214" cy="142" r="36" />
<circle class="eye__pupil" cx="214" cy="142" r="15" />
</g>
</svg>
</section>
.gaze-hero {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--face: #d2b999;
--cheek: #d29b97;
--white: #f2f1ed;
--pupil: #111111;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 48px;
min-height: 100vh;
box-sizing: border-box;
padding: 96px clamp(24px, 7.5vw, 96px);
background: var(--bg);
color: var(--ink);
}
.gaze-hero__label {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
}
.gaze-hero__title {
margin: 24px 0 0;
font-size: clamp(32px, 7vw, 64px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.gaze-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: auto-phrase;
}
.gaze-hero__button {
display: inline-flex;
align-items: center;
height: 48px;
margin-top: 40px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--bg);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.gaze-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
.gaze-face { display: block; width: clamp(200px, 26vw, 320px); height: auto; }
.gaze-face__skin { fill: var(--face); }
.gaze-face__cheek { fill: var(--cheek); }
.gaze-face__mouth { fill: var(--pupil); }
/* 輪郭の線が無いので、まばたきで閉じるほど(--shut: 0 → 1)白目を瞳の色へ移し、閉じた目を濃い線にする */
.eye { --shut: 0; }
.eye__white {
fill: var(--white);
fill: color-mix(in srgb, var(--pupil) calc(var(--shut) * 100%), var(--white));
}
.eye__pupil { fill: var(--pupil); }
/* 狭い画面では、キャラクターを文の上に置く */
@media (max-width: 760px) {
.gaze-hero {
grid-template-columns: minmax(0, 1fr);
align-content: center;
}
.gaze-face {
order: -1;
width: 200px;
}
}
import { gsap } from "gsap";
const GAIN = 0.06; // 瞳のずれ = ポインタまでの距離 × これ(白目の縁で止める)
const LAG = 0.35; // 瞳が追いつくまでの秒
const NEAR = 40; // 顔の縁からこの距離(px)より内に入ったら、まばたきする
const REARM = 70; // 顔の縁からこの距離より外へ出たら、次のまばたきを撃てる
// 指で触る端末にはなぞるポインタが無いので、瞳は動かさない
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".gaze-hero").forEach((hero) => {
const svg = hero.querySelector(".gaze-face");
if (!svg || !canHover) return;
const skin = svg.querySelector(".gaze-face__skin");
const size = svg.viewBox.baseVal.width;
const eyes = [...svg.querySelectorAll(".eye")].map((eye) => {
const white = eye.querySelector(".eye__white");
const pupil = eye.querySelector(".eye__pupil");
// 瞳が白目の縁から出ない範囲(白目の半径 − 瞳の半径 − 余白 6)
const max = white.r.baseVal.value - pupil.r.baseVal.value - 6;
// quickTo に duration 0 を渡すと動かないので、動きを減らす設定では set で置く
const to = (prop) => (reduceMotion ? (v) => gsap.set(pupil, { [prop]: v }) : gsap.quickTo(pupil, prop, { duration: LAG, ease: "power3" }));
return { white, max, x: to("x"), y: to("y") };
});
// (x, y) を見る。目ごとに、目の中心からの向きで瞳をずらす(近くでは寄り目になる)。null なら中央へ戻す
const look = (x, y) => {
const scale = svg.getBoundingClientRect().width / size;
for (const m of eyes) {
const r = m.white.getBoundingClientRect();
const dx = x === null ? 0 : (x - (r.left + r.width / 2)) / scale;
const dy = x === null ? 0 : (y - (r.top + r.height / 2)) / scale;
const dist = Math.hypot(dx, dy);
const k = dist > 0 ? Math.min(m.max, dist * GAIN) / dist : 0;
m.x(dx * k);
m.y(dy * k);
}
};
// まばたき: 目を縦に 0.1 倍までつぶして戻す。閉じきる間際だけ --shut で白目を瞳の色へ移す(CSS の color-mix)
const lids = svg.querySelectorAll(".eye");
gsap.set(lids, { transformOrigin: "50% 50%" });
const blink = gsap
.timeline({ paused: true })
.to(lids, { scaleY: 0.1, duration: 0.08, ease: "power2.in" })
.to(lids, { "--shut": 1, duration: 0.08, ease: "expo.in" }, 0)
.to(lids, { scaleY: 1, duration: 0.16, ease: "power2.out" }, 0.13)
.to(lids, { "--shut": 0, duration: 0.16, ease: "expo.out" }, 0.13);
let armed = true;
hero.addEventListener("pointermove", (event) => {
if (event.pointerType === "touch") return;
look(event.clientX, event.clientY);
const face = skin.getBoundingClientRect();
const edge = Math.hypot(event.clientX - (face.left + face.width / 2), event.clientY - (face.top + face.height / 2)) - face.width / 2;
if (armed && edge < NEAR) {
armed = false; // 顔の縁でまばたきが続かないよう、一度離れるまで撃ち直さない
if (!reduceMotion) blink.restart();
} else if (!armed && edge > REARM) armed = true;
});
hero.addEventListener("pointerleave", () => {
look(null);
armed = true;
});
// キーボードで中のリンクへ移ったら、その項目を見る。外れたら中央へ戻す
hero.addEventListener("focusin", (event) => {
if (!event.target.matches(":focus-visible")) return;
const r = event.target.getBoundingClientRect();
look(r.left + r.width / 2, r.top + r.height / 2);
});
hero.addEventListener("focusout", () => look(null));
});