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

カーソル

リンクやボタンの上でカーソルの輪が膨らむ

デモ

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

仕様

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

しくみと調整

ふだんは小さな点と輪がポインタについてくるだけですが、リンクやボタンに乗ると輪がふくらんで薄く色づき、作品の画像の上ではさらに大きな塗りつぶしの円になって、中に「詳しく見る」と表示されます。点はポインタにぴったりつき、輪は少し遅れて追いかけます。

どの状態にするかは、ポインタが乗った要素から closest で親をたどり、リンクやボタンの中か、作品の画像の中かを見ているだけです。あとから追加したリンクや、差し替えたページの中にもそのまま効きます。直径は、ふだんの36pxからリンクの上で84px、画像の上で144pxへ。変化はどれも0.4秒の power3.out で、入るたび・出るたびにそのときの大きさから補間し直すので、要素の間を素早く渡っても輪が途中で跳びません。

大きさは scale ではなく、幅と高さで変えました。拡大して大きくすると、輪の線まで一緒に太ってしまいます。中心をポインタに合わせる xPercent と yPercent の-50も外せません。これが無いと輪は左上の角を基準に置かれ、ふくらむたびに右下へずれていきます。状態が変わるたびに新しいトゥイーンを作るので、overwrite を auto にして、前のトゥイーンが同じ幅や色を動かし続けないようにしています。

キーボードでリンクへ移ったときも、輪をその要素の中央へ動かして同じ見た目にします。ただ、マウスでクリックしたときにもフォーカスは移るので、そのたびに中央へ動かすと、クリックした位置から輪が跳んで見えます。輪が動くのは :focus-visible が当たるときだけです。

「詳しく見る」の文言は差し替えられるので、「再生する」にすれば動画の上、「ドラッグ」にすればスライダーの上でも使えます。リンクと作品で輪の大きさがはっきり違うため、押す前から、そこがリンクなのか作品なのかを輪で見分けられます。

コード

HTML
<!-- 輪が膨らむのは a と button の上。「詳しく見る」を表示したい所には data-cursor="view" を付ける -->
<section class="intro" aria-labelledby="intro-title">
  <h2 class="intro__title" id="intro-title">心を動かす、<br>ものづくりを。</h2>
  <p class="intro__lead">ここに説明文が入ります。</p>
  <p class="intro__actions">
    <a class="button" href="/contact/">制作を相談する</a>
    <a class="text-link" href="/work/">実績を見る <span aria-hidden="true">→</span></a>
  </p>
  <a class="work" href="/work/shiosai-coffee/" data-cursor="view">
    <img src="/img/shiosai-coffee.jpg" alt="しおさい珈琲のブランディング" width="464" height="432">
  </a>
</section>

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

CSS
:root {
  --cursor-rest: 36px; /* ふだんの輪の直径 */
  --cursor-link: 84px; /* リンクやボタンの上 */
  --cursor-view: 144px; /* data-cursor="view" の上 */
  --cursor-ink: #262626; /* 輪の線と点 */
  --cursor-accent: #4a5b66; /* 膨らんだ輪の塗り。白い文言が載るので濃い色に */
}

.cursor-ring,
.cursor-dot {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 9999;
  border-radius: 50%;
  /* 下のリンクやボタンを押せるようにする */
  pointer-events: none;
  /* JS が有効にしたときだけ表示する。タッチ端末やスクリプトが動かないときは本物のカーソルのまま */
  display: none;
}

.cursor-ring {
  width: var(--cursor-rest);
  height: var(--cursor-rest);
  box-sizing: border-box;
  border: 2px solid var(--cursor-ink);
  /* 文言が輪より広いときも、左右へ均等にはみ出すよう flex で中央に置く */
  align-items: center;
  justify-content: center;
}

.cursor-ring__label {
  flex: none;
  color: #ffffff;
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.04em;
  white-space: nowrap;
  opacity: 0;
}

.cursor-dot {
  width: 10px;
  height: 10px;
  background: var(--cursor-ink);
}

.has-custom-cursor .cursor-ring { display: flex; }
.has-custom-cursor .cursor-dot { display: block; }

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

/* ページの外へ出たあいだは隠す(端に貼り付いたまま残らないように) */
.cursor-away .cursor-ring,
.cursor-away .cursor-dot {
  visibility: hidden;
}

.intro { color: var(--cursor-ink); }
.intro__title { margin: 0; font-size: clamp(44px, 7vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.intro__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.intro__actions { display: flex; align-items: center; gap: 32px; margin: 48px 0; font-size: 15px; font-weight: 600; }

.button {
  display: inline-flex;
  align-items: center;
  height: 48px;
  padding: 0 24px;
  border-radius: 999px;
  background: #262626;
  color: #ffffff;
  text-decoration: none;
}

.text-link { color: inherit; text-underline-offset: 0.4em; }
.work { display: block; width: fit-content; max-width: 100%; border-radius: 4px; overflow: hidden; }
.work img { display: block; max-width: 100%; height: auto; }
.intro a:focus-visible { outline: 2px solid var(--cursor-ink); outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const ring = document.querySelector(".cursor-ring");
const dot = document.querySelector(".cursor-dot");
// タッチ端末にはポインタが無いので、独自カーソルは表示せず本物のカーソルのままにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (ring && dot && canHover) {
  const label = ring.querySelector(".cursor-ring__label");
  const css = getComputedStyle(ring);
  const read = (name) => css.getPropertyValue(name).trim();
  const [ar, ag, ab] = gsap.utils.splitColor(read("--cursor-accent"));
  const [ir, ig, ib] = gsap.utils.splitColor(read("--cursor-ink"));
  // 塗りの濃さ(0〜1)に合わせて、輪の線を薄くする。1 で線が消えて、塗りの円になる
  const tint = (fill) => ({ backgroundColor: `rgba(${ar}, ${ag}, ${ab}, ${fill})`, borderColor: `rgba(${ir}, ${ig}, ${ib}, ${1 - fill})` });
  const STATES = {
    rest: { size: read("--cursor-rest"), fill: 0, label: 0 },
    link: { size: read("--cursor-link"), fill: 0.16, label: 0 },
    view: { size: read("--cursor-view"), fill: 1, label: 1 },
  };
  const GROW = reduceMotion ? 0 : 0.4; // 大きさと色を変える時間(秒)。動きを減らす設定では一瞬で
  const CHASE = 0.25; // 輪がポインタに追いつく時間(秒)

  // 中心をポインタに合わせる。大きさを変えても中心がずれない
  gsap.set([ring, dot], { xPercent: -50, yPercent: -50 });
  gsap.set(ring, tint(0));
  document.documentElement.classList.add("has-custom-cursor");
  // 動きを減らす設定では遅れなし(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (value) => gsap.set(ring, { [prop]: value }) : gsap.quickTo(ring, prop, { duration: CHASE, ease: "power3" }));
  const moveX = follow("x");
  const moveY = follow("y");
  const place = (x, y) => {
    gsap.set(dot, { x, y }); // 点は遅れなし
    moveX(x);
    moveY(y);
  };

  let current = STATES.rest;
  // 今の大きさと色から、次の状態へ補間し直す。大きさは scale ではなく width / height で変える(scale だと輪の線まで太る)
  const to = (state) => {
    if (state === current) return;
    current = state;
    const vars = { duration: GROW, ease: "power3.out", overwrite: "auto" };
    gsap.to(ring, { width: state.size, height: state.size, ...tint(state.fill), ...vars });
    gsap.to(label, { opacity: state.label, ...vars });
    gsap.to(dot, { opacity: 1 - state.label, ...vars }); // 文言が表示されている間は、点を消して重ねない
  };
  // 乗った要素から状態を決める。あとから足したリンクや、差し替えたページの中にも効く
  const stateOf = (el) => (el.closest("[data-cursor='view']") ? STATES.view : el.closest("a, button") ? STATES.link : STATES.rest);

  window.addEventListener("pointermove", (event) => place(event.clientX, event.clientY));
  document.addEventListener("pointerover", (event) => to(stateOf(event.target)));
  const page = document.documentElement;
  page.addEventListener("pointerleave", () => page.classList.add("cursor-away"));
  page.addEventListener("pointerenter", () => page.classList.remove("cursor-away"));

  // キーボードで移ったときも、輪をその要素の中央へ動かして同じ見た目にする(マウスで押したときの focus は除く)
  let byKeys = false;
  document.addEventListener("focusin", (event) => {
    byKeys = event.target.matches(":focus-visible");
    if (!byKeys) return;
    const box = event.target.getBoundingClientRect();
    place(box.left + box.width / 2, box.top + box.height / 2);
    to(stateOf(event.target));
  });
  document.addEventListener("focusout", () => {
    if (byKeys) to(STATES.rest);
    byKeys = false;
  });
}

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

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