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

背景のドットが、カーソルに近いものほど大きく明るくなる

デモ

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

仕様

用途
カーソル
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ヒーローの背景に、細かいドットの格子を CSS の radial-gradient の模様で敷き、その上に全面の canvas を重ねます。ポインタが入ると、光の中心から半径の中にあるドットだけを canvas に描き直し、近いものほど大きく濃い明るいドットで、地のドットの上から覆います。近さの効き方は余弦の山で、真下でいちばん大きく明るく、半径の縁でなめらかに地のドットへ戻ります。光の中心には、ごく淡いにじみも重ねています。

ドットの間隔は 24px、地のドットの半径は 1.3px、いちばん近いドットは 5px まで大きくなり、光が届く半径は 230px です。光の中心は quickTo で 0.35 秒かけてポインタに追いつくので、明るいまとまりが少し遅れてついてきます。ヒーローに入ると 0.5 秒で明るさが立ち上がり、出ると 0.6 秒でしぼみます。

描き直すのは、半径を囲む四角の中のドットだけにします。画面いっぱいのドットを毎コマ描くと、大きな画面では数千個になって重くなります。描くのは光が動いているあいだだけで、ポインタが止まって追いつけば描き直しも止まります。canvas の大きさは devicePixelRatio を掛けた画素数にし、描く座標は CSS の px のままにします。掛けないと高精細の画面でドットがにじみ、地の模様より甘く見えます。

canvas と地の模様にはポインタを通し、上のリンクやボタンをそのまま押せるようにします。指で触る端末ではつながず、地のドットの格子だけを見せます。キーボードでボタンへ移ったときは、そのボタンの中央を光らせます。動きを減らす設定では、遅れと立ち上がりを無くし、その場で光らせます。

つまみは、ドットの間隔と大きさ、光が届く半径、追いつく時間です。半径を広げるとスポットライトのような大きな光になり、狭めるとカーソルのすぐ下だけが灯る控えめな表情になります。中心から波が広がる格子と違い、ポインタとの距離だけで決まるので、ヒーローやセクションの背景、サービス紹介や採用ページの見出しの後ろに、軽く敷ける触り心地として向いています。

コード

HTML
<section class="dot-hero" aria-labelledby="dot-hero-title">
  <!-- カーソルの近くのドットを明るく描く層。飾りなので読み上げから外す -->
  <canvas class="dot-hero__glow" aria-hidden="true"></canvas>
  <div class="dot-hero__body">
    <p class="dot-hero__eyebrow">Design studio</p>
    <h1 class="dot-hero__title" id="dot-hero-title">細部まで、<br>ていねいに。</h1>
    <p class="dot-hero__lead">ここに説明文が入ります。</p>
    <div class="dot-hero__actions">
      <a class="dot-hero__button dot-hero__button--primary" href="/contact/">相談する</a>
      <a class="dot-hero__button" href="/work/">実績を見る</a>
    </div>
  </div>
</section>

CSS
.dot-hero {
  --bg: #111111;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --dot: #3a3a38; /* 地のドット */
  --dot-light: 147, 178, 201; /* 光ったドットと光のにじみの色(R, G, B)。パレットのスレートの彩度を 1.25 倍にした値 */
  --dot-gap: 24px; /* ドットの間隔 */
  --dot-size: 1.3px; /* 地のドットの半径 */
  --dot-max: 5px; /* いちばん近いドットの半径 */
  --glow-radius: 230px; /* 光が届く半径 */
  position: relative;
  display: grid;
  align-content: center;
  min-height: 100vh;
  box-sizing: border-box;
  padding: 96px clamp(24px, 7.5vw, 96px);
  overflow: hidden;
  /* 地のドットは背景の模様で敷く。JS が動かなくても、指で触る端末でも、この格子は見える */
  background:
    radial-gradient(circle, var(--dot) var(--dot-size), transparent calc(var(--dot-size) + 0.6px)) 0 0 / var(--dot-gap) var(--dot-gap),
    var(--bg);
  color: var(--ink);
}

/* 明るいドットだけを描く層。下のリンクを押せるよう、ポインタを通す */
.dot-hero__glow {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.dot-hero__body {
  position: relative;
}

.dot-hero__eyebrow {
  margin: 0 0 24px;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.dot-hero__title {
  margin: 0;
  font-size: clamp(44px, 7vw, 72px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}

.dot-hero__lead {
  max-width: 21.5em; /* 広い画面では「実装まで」のあとで折り返す */
  margin: 24px 0 40px;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: keep-all; /* 折り返すのは HTML の wbr の所だけ(auto-phrase は「手が/けて」と割った) */
}

.dot-hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}

.dot-hero__button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 32px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  color: var(--ink);
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.dot-hero__button--primary {
  background: var(--ink);
  color: var(--bg);
}

.dot-hero__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

JavaScript
import { gsap } from "gsap";

// 指で触る端末にはポインタが無いので、つながずに地のドットだけにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".dot-hero").forEach((hero) => {
  const canvas = hero.querySelector(".dot-hero__glow");
  if (!canHover || !canvas) return;
  const g = canvas.getContext("2d");
  const css = getComputedStyle(hero);
  const read = (name) => parseFloat(css.getPropertyValue(name));
  const [gap, size, max, radius] = ["--dot-gap", "--dot-size", "--dot-max", "--glow-radius"].map(read);
  const rgb = css.getPropertyValue("--dot-light").trim();
  const glow = { x: 0, y: 0, power: 0 }; // 光の中心と強さ(0〜1)
  let [width, height] = [0, 0];

  // 半径の中のドットだけを、近いほど大きく濃く描く。外は CSS の地のドットのまま
  const draw = () => {
    g.clearRect(0, 0, width, height);
    if (glow.power < 0.005) return;
    const halo = g.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, radius * 1.2);
    halo.addColorStop(0, `rgba(${rgb}, ${0.08 * glow.power})`);
    halo.addColorStop(1, `rgba(${rgb}, 0)`);
    g.fillStyle = halo;
    g.fillRect(0, 0, width, height);
    g.fillStyle = `rgb(${rgb})`;
    const [i0, i1] = [Math.max(0, Math.floor((glow.x - radius) / gap)), Math.ceil((glow.x + radius) / gap)];
    const [j0, j1] = [Math.max(0, Math.floor((glow.y - radius) / gap)), Math.ceil((glow.y + radius) / gap)];
    for (let j = j0; j <= j1; j++) {
      for (let i = i0; i <= i1; i++) {
        const cx = (i + 0.5) * gap; // 背景の模様と同じ、升目の中心
        const cy = (j + 0.5) * gap;
        const d = Math.hypot(cx - glow.x, cy - glow.y);
        if (d >= radius) continue;
        const k = (0.5 + 0.5 * Math.cos((Math.PI * d) / radius)) * glow.power; // 真下で 1、縁で 0
        g.globalAlpha = k;
        g.beginPath();
        g.arc(cx, cy, size + (max - size) * k, 0, Math.PI * 2);
        g.fill();
      }
    }
    g.globalAlpha = 1;
  };

  const resize = () => {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    ({ width, height } = hero.getBoundingClientRect());
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(height * dpr);
    g.setTransform(dpr, 0, 0, dpr, 0, 0);
    draw();
  };

  // 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const set = (prop) => (value) => {
    glow[prop] = value;
    draw();
  };
  const chase = (prop) => (reduceMotion ? set(prop) : gsap.quickTo(glow, prop, { duration: 0.35, ease: "power3", onUpdate: draw }));
  const toX = chase("x");
  const toY = chase("y");
  const light = (on) => gsap.to(glow, { power: on ? 1 : 0, duration: reduceMotion ? 0 : on ? 0.5 : 0.6, ease: "power2.out", overwrite: "auto", onUpdate: draw });

  const aim = (x, y) => {
    // しぼみきっていれば、光を遠くから飛ばさず、その場で光らせる
    if (glow.power < 0.02) {
      toX(x, x);
      toY(y, y);
    }
    toX(x);
    toY(y);
  };
  const local = (event) => {
    const box = hero.getBoundingClientRect();
    return [event.clientX - box.left, event.clientY - box.top];
  };

  hero.addEventListener("pointerenter", (event) => {
    if (event.pointerType === "touch") return;
    aim(...local(event));
    light(true);
  });
  hero.addEventListener("pointermove", (event) => event.pointerType !== "touch" && aim(...local(event)));
  hero.addEventListener("pointerleave", () => light(false));

  // キーボードでボタンへ移ったときは、そのボタンの中央を光らせる
  hero.addEventListener("focusin", (event) => {
    if (!event.target.matches(":focus-visible")) return;
    const box = hero.getBoundingClientRect();
    const r = event.target.getBoundingClientRect();
    aim(r.left + r.width / 2 - box.left, r.top + r.height / 2 - box.top);
    light(true);
  });
  hero.addEventListener("focusout", () => hero.matches(":hover") || light(false));
  window.addEventListener("resize", resize);
  resize();
});

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

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