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

見出しの横のキャラクターの目がカーソルを追い、近づくとまばたきする

デモ

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

仕様

用途
カーソル
動作
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 の変数)です。風景の層をずらして奥行きを出す演出や、カーソルそのものの形を変える演出と違い、ページの中のキャラクターが読む人を見返すので、相談窓口やサポートのページ、子ども向けやペット関連のサービス、キャラクターのいるブランドのヒーローに向いています。

コード

HTML
<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>

CSS
.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;
  }
}

JavaScript
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));
});

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

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