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

カーソルの輪がボタンに吸い付いて形を変え、離れると戻る

デモ

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

仕様

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

しくみと調整

独自カーソルの輪を画面に固定し、ポインタを少し遅れて追わせます。data-sticky を付けたボタンやリンクに乗ると、輪はその要素の中心へ移り、要素を 8px のすき間で囲む形に変わります。横長の丸帯のボタンなら横長に、丸いアイコンボタンなら丸く、ナビのリンクなら小さな丸帯になります。離れると元の丸に戻り、またポインタを追います。

形と位置は 0.35 秒の power3.out で移すので、近づいた瞬間に吸い寄せられ、最後はゆっくり収まります。乗っているあいだも輪の中心は、中心からポインタまでのずれの 8% だけポインタのほうへ引かれ、ボタンの上で手を動かすと輪がわずかに付いてきます。引く割合を 0.15 より大きくすると、輪がボタンからずれて見えるので控えめにします。ふだんの輪は直径 40px で、ポインタに 0.25 秒で追いつきます。

形は scale ではなく、幅・高さ・角の丸みで変えます。拡大で合わせると、輪の線まで太くなるためです。囲む形は、乗った要素の位置・大きさ・角の丸みを、乗るたびに測って決めます。中心をポインタに合わせる xPercent と yPercent の -50 も欠かせません。これが無いと、形が変わるたびに輪が右下へずれていきます。

キーボードで移ったときも、:focus-visible のときだけ、輪を同じ形で吸い付かせます。輪がフォーカスの印を兼ねるので、そのあいだは要素の枠線を消し、ポインタのほうへは引きません。指で触る端末では独自カーソルを出さず、枠線も本物のカーソルもそのまま残します。点は白で描いて mix-blend-mode を difference にし、明るいボタンの上では暗く見えるようにしています。動きを減らす設定では、遅らせずに一瞬で形を変えます。

つまみは、ふだんの輪の直径、囲むときのすき間、引く割合、形を変える時間です。すき間を 0 にすると、輪がボタンの縁に重なって、枠線を描き足したように見えます。ヒーローの CTA やナビのリンク、丸いアイコンボタンが並ぶポートフォリオやスタジオのサイトで、押せる所をはっきり伝えたいときに向いています。

コード

HTML
<!-- 輪が吸い付くのは data-sticky を付けた要素。要素の大きさと角の丸みに合わせて、輪の形が変わる -->
<section class="talk" aria-labelledby="talk-title">
  <p class="talk__eyebrow">Let's talk</p>
  <h2 class="talk__title" id="talk-title"><span class="talk__line">伝わるサイトを、</span><span class="talk__line">一緒に。</span></h2>
  <p class="talk__lead">ここに説明文が入ります。</p>
  <p class="talk__actions">
    <a class="button button--primary" href="/contact/" data-sticky>制作を相談する</a>
    <a class="button" href="/work/" data-sticky>実績を見る</a>
    <a class="icon-button" href="/journal/" data-sticky aria-label="記事を読む">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 17L17 7M9 7h8v8" /></svg>
    </a>
  </p>
</section>

<!-- 独自カーソル。飾りなので読み上げから外し、body の終わりに置く -->
<div class="sticky-ring" aria-hidden="true"></div>
<div class="sticky-dot" aria-hidden="true"></div>

CSS
:root {
  --cursor-ink: #eeece6; /* 輪の線 */
  --cursor-size: 40px;   /* ふだんの輪の直径 */
  --cursor-gap: 8px;     /* 吸い付いた要素と輪のすき間 */
}

.sticky-ring,
.sticky-dot {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 9999;
  /* 下のリンクやボタンを押せるようにする */
  pointer-events: none;
  /* JS がつないだときだけ出す。指で触る端末やスクリプトが動かないときは本物のカーソルのまま */
  display: none;
}

.sticky-ring {
  width: var(--cursor-size);
  height: var(--cursor-size);
  box-sizing: border-box;
  border: 2px solid var(--cursor-ink);
  border-radius: 999px;
}

/* 点は白 + difference。明るいボタンの上では暗く見える */
.sticky-dot { width: 8px; height: 8px; border-radius: 50%; background: #ffffff; mix-blend-mode: difference; }

.has-sticky-cursor .sticky-ring,
.has-sticky-cursor .sticky-dot { display: block; }

/* 本物のカーソルは、独自カーソルを出したときだけ隠す */
.has-sticky-cursor,
.has-sticky-cursor a,
.has-sticky-cursor button { cursor: none; }

/* 輪がフォーカスの印を兼ねるので、独自カーソルを出したときだけ枠線を輪に任せる */
.has-sticky-cursor [data-sticky]:focus-visible { outline: none; }

/* ページの外へ出たあいだと、まだ動かしていないあいだは隠す */
.cursor-away .sticky-ring,
.cursor-away .sticky-dot { visibility: hidden; }

.talk { padding: 96px 24px; background: #111111; color: #eeece6; text-align: center; }
.talk__eyebrow { margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #a8a6a0; }
.talk__title { margin: 20px 0 0; font-size: clamp(32px, 7vw, 72px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: auto-phrase; }
.talk__line { display: inline-block; } /* 行の切れ目。狭い画面ではこの間で折り返す */
.talk__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.talk__actions { display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 24px; margin: 40px 0 0; }

.button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  box-sizing: border-box;
  padding: 0 32px;
  border: 1px solid currentColor;
  border-radius: 999px;
  color: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.button--primary { border-color: #eeece6; background: #eeece6; color: #111111; }

.icon-button {
  display: inline-grid;
  place-items: center;
  width: 48px;
  height: 48px;
  box-sizing: border-box;
  border: 1px solid currentColor;
  border-radius: 50%;
  color: inherit;
}

/* viewBox 24 を 20px で描くので、線 2px は stroke-width 2.4 */
.icon-button svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.talk a:focus-visible { outline: 2px solid #eeece6; outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const ring = document.querySelector(".sticky-ring");
const dot = document.querySelector(".sticky-dot");
// 指で触る端末にはポインタが無いので、独自カーソルは出さず本物のカーソルのままにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const PULL = 0.08; // 吸い付いたあと、中心からポインタのほうへ寄る割合

// 見出しの行ごとに、行末の約物(「、」「。」)は数えず、その手前までの字面の中央をそろえる(約物は右へぶら下がる)。同じ行の span はまとめて測り、ずれは em で持つ
const centerLines = () => document.querySelectorAll(".talk__title").forEach((title) => {
  const style = getComputedStyle(title), rows = new Map();
  const ruler = Object.assign(document.createElement("canvas").getContext("2d"), { font: `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, letterSpacing: style.letterSpacing, textAlign: "left", lang: "ja" });
  title.querySelectorAll(".talk__line").forEach((line) => rows.set(line.offsetTop, [...(rows.get(line.offsetTop) ?? []), line]));
  rows.forEach((row) => {
    const text = row.map((line) => line.textContent).join(""), { width } = ruler.measureText(text), ink = ruler.measureText(text.replace(/[、。,.」』)]+$/u, ""));
    row.forEach((line) => (line.style.translate = `${(width / 2 - (ink.actualBoundingBoxRight - ink.actualBoundingBoxLeft) / 2) / parseFloat(style.fontSize)}em`));
  });
});
document.fonts.ready.then(() => { centerLines(); window.addEventListener("resize", centerLines); });

if (ring && dot && canHover) {
  const page = document.documentElement;
  const css = getComputedStyle(page);
  const size = parseFloat(css.getPropertyValue("--cursor-size"));
  const gap = parseFloat(css.getPropertyValue("--cursor-gap"));
  const SHAPE = reduceMotion ? 0 : 0.35; // 形を変える時間(秒)。動きを減らす設定では一瞬で

  // 中心をポインタに合わせる。形を変えても中心がずれない
  gsap.set([ring, dot], { xPercent: -50, yPercent: -50 });
  page.classList.add("has-sticky-cursor", "cursor-away");
  // 輪は 0.25 秒でポインタに追いつく。動きを減らす設定では遅れずに置く(quickTo は duration 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (v) => gsap.set(ring, { [prop]: v }) : gsap.quickTo(ring, prop, { duration: 0.25, ease: "power3" }));
  const moveX = follow("x");
  const moveY = follow("y");
  const pointer = { x: 0, y: 0 };
  let stuck = null;
  let byKeys = false; // キーボードで吸い付いたときは、ポインタのほうへ引かない

  // 吸い付いていれば要素の中心へ(ポインタのほうへ少しだけ引く)、いなければポインタへ
  const aim = () => {
    const box = stuck?.getBoundingClientRect();
    const cx = box ? box.left + box.width / 2 : pointer.x;
    const cy = box ? box.top + box.height / 2 : pointer.y;
    const pull = byKeys ? 0 : PULL;
    moveX(cx + (pointer.x - cx) * pull);
    moveY(cy + (pointer.y - cy) * pull);
  };

  // 隠れていた輪を、左上から飛んでこないようその場に置いてから出す
  const show = (x, y) => {
    if (!page.classList.contains("cursor-away")) return;
    gsap.set(ring, { x, y });
    page.classList.remove("cursor-away");
  };

  // 要素をすき間で囲む形へ(外れたら、ふだんの丸へ)。scale ではなく幅と高さで変える(輪の線が太らない)
  const stick = (el) => {
    if (el === stuck) return;
    stuck = el;
    const box = el?.getBoundingClientRect();
    const radius = el ? parseFloat(getComputedStyle(el).borderTopLeftRadius) : 0;
    const shape = el
      ? { width: box.width + gap * 2, height: box.height + gap * 2, borderRadius: Math.min(radius + gap, box.height / 2 + gap) }
      : { width: size, height: size, borderRadius: size / 2 };
    gsap.to(ring, { ...shape, duration: SHAPE, ease: "power3.out", overwrite: "auto" });
    aim();
  };

  window.addEventListener("pointermove", (event) => {
    pointer.x = event.clientX;
    pointer.y = event.clientY;
    gsap.set(dot, { x: pointer.x, y: pointer.y }); // 点は遅れなし
    show(pointer.x, pointer.y);
    // マウスが動いたら、キーボードの焦点からポインタへ持ち主を戻す
    if (byKeys) {
      byKeys = false;
      stick(event.target.closest?.("[data-sticky]") ?? null);
    }
    aim();
  });
  document.addEventListener("pointerover", (event) => stick(event.target.closest("[data-sticky]")));
  page.addEventListener("pointerleave", () => page.classList.add("cursor-away"));
  window.addEventListener("scroll", aim, { passive: true }); // 吸い付いた要素がスクロールで動いても外れない

  // キーボードで移ったときも同じ形で吸い付く(マウスで押したときの焦点は除く)
  document.addEventListener("focusin", (event) => {
    if (!event.target.matches("[data-sticky]:focus-visible")) return;
    byKeys = true;
    const box = event.target.getBoundingClientRect();
    show(box.left + box.width / 2, box.top + box.height / 2);
    stick(event.target);
  });
  document.addEventListener("focusout", () => {
    if (!byKeys) return;
    byKeys = false;
    stick(null);
  });
}

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

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