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

カーソルが作品の画像の上で「詳しく見る」の丸いラベルに変わる

デモ

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

仕様

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

しくみと調整

ふだんは小さな点のカーソルを画面に固定しておき、作品の上に入ったら丸を大きくして、中の文言を下から送り出します。文言と丸の色は作品ごとに data-cursor の組で決めておき、入った先の設定をそのまま丸に渡します。丸には overflow の hidden を付けてあるので、文言は丸の外へはみ出さずに切り抜かれます。

点は直径 14px、ラベルの丸は 136px で、大きくなる時間も文言が入れ替わる時間も 0.45 秒にそろえています。丸がポインタに追いつく時間は 0.2 秒と短くして、点でいるあいだはほとんど遅れて見えないようにしています。大きさを scale で変えないのがこの作りの肝で、拡大すると中の文字まで一緒に伸びてしまうため、幅と高さの数値そのものを動かします。

作品から作品へ直に渡ると、前の文言が上へ抜けながら次の文言が下から入ります。このとき前の動きが残っていると値を取り合って途中で飛ぶので、overwrite を auto にして古いほうを捨てさせます。中心をポインタに合わせる指定も要り、抜けると大きくなるたびに丸が右下へふくらんで見えます。

つまみは、点とラベルの直径、大きくなる時間、文言が入れ替わる時間の 4 つです。文言を「ドラッグ」に替えれば横に流れるスライダーにも使え、三角の印を添えれば動画の再生にも読み替えられます。作品一覧や動画のサムネイルに向いています。ポインタの無い端末では出さず、本物のカーソルのままにします。

コード

HTML
<section class="work" aria-labelledby="work-title">
  <h2 class="work__title" id="work-title">Selected work</h2>
  <ul class="work__grid">
    <li>
      <!-- data-cursor は丸に出す文言の名前、data-cursor-color は丸の地の色 -->
      <a class="work__tile" href="/work/obsidian/" data-cursor="view" data-cursor-color="#ffffff">
        <img src="/img/obsidian.jpg" alt="Obsidian の製品映像">
      </a>
      <p class="work__caption"><span>Obsidian</span><span>製品映像/2026年</span></p>
    </li>
    <li>
      <a class="work__tile" href="/work/showreel/" data-cursor="play" data-cursor-color="#ffd64a">
        <img src="/img/showreel.jpg" alt="2026年のショーリール">
      </a>
      <p class="work__caption"><span>Showreel 2026</span><span>映像</span></p>
    </li>
  </ul>
</section>

<!-- 独自カーソル。文言は data-cursor の値ごとに 1 つずつ入れておく -->
<div class="cursor" aria-hidden="true">
  <span class="cursor__label" data-label="view">詳しく見る</span>
  <span class="cursor__label" data-label="play">再生する</span>
</div>
CSS
.cursor {
  --dot: 14px;
  --label: 136px;
  --dot-color: #262626;
  position: fixed;
  left: 0;
  top: 0;
  z-index: 9999;
  width: var(--dot);
  height: var(--dot);
  border-radius: 50%;
  background: var(--dot-color);
  /* 文言を丸の中だけに見せる。丸からはみ出した分は切り抜かれる */
  overflow: hidden;
  pointer-events: none;
  display: none;
}

.cursor__label {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #262626;
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

/* JS がつないだときだけ出す。ポインタの無い端末では本物のカーソルのまま */
.has-custom-cursor .cursor {
  display: block;
}

.has-custom-cursor,
.has-custom-cursor a {
  cursor: none;
}

.work__grid {
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 24px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.work__tile {
  display: block;
  overflow: hidden;
  border-radius: 4px;
}

.work__tile img {
  display: block;
  width: 100%;
  height: 360px;
  object-fit: cover;
}

.work__tile:focus-visible {
  outline: 2px solid #262626;
  outline-offset: 4px;
}

.work__caption {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin: 16px 0 0;
}
JavaScript
import { gsap } from "gsap";

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

if (cursor && canHover) {
  const style = getComputedStyle(cursor);
  const dot = style.getPropertyValue("--dot").trim();
  const label = style.getPropertyValue("--label").trim();
  const dotColor = style.getPropertyValue("--dot-color").trim();
  // 大きさを変えても位置がずれないよう、中心をポインタに合わせる
  gsap.set(cursor, { xPercent: -50, yPercent: -50 });
  gsap.set(cursor.querySelectorAll("[data-label]"), { yPercent: 100 });
  document.documentElement.classList.add("has-custom-cursor");

  // 動きを減らす設定では、遅れも送りもほぼ一瞬にする
  const chase = reduceMotion ? 0 : 0.2;
  const swap = reduceMotion ? 0.05 : 0.45;
  const moveX = gsap.quickTo(cursor, "x", { duration: chase, ease: "power3" });
  const moveY = gsap.quickTo(cursor, "y", { duration: chase, ease: "power3" });
  window.addEventListener("pointermove", (event) => {
    moveX(event.clientX);
    moveY(event.clientY);
  });

  document.querySelectorAll("[data-cursor]").forEach((target) => {
    const text = cursor.querySelector(`[data-label="${target.dataset.cursor}"]`);
    if (!text) return;

    // 大きさは scale ではなく width / height で変える(scale だと中の文字まで伸びる)
    const grow = (size, color) => {
      gsap.to(cursor, { width: size, height: size, backgroundColor: color, duration: swap, ease: "power3.out", overwrite: "auto" });
    };
    // 対象から対象へ直に渡ると、前の文言が上へ抜けながら次の文言が下から入る
    const show = () => {
      grow(label, target.dataset.cursorColor || dotColor);
      gsap.fromTo(text, { yPercent: 100 }, { yPercent: 0, duration: swap, ease: "power3.out", overwrite: "auto" });
    };
    const hide = () => {
      grow(dot, dotColor);
      gsap.to(text, { yPercent: -100, duration: swap, ease: "power3.out", overwrite: "auto" });
    };

    target.addEventListener("pointerenter", show);
    target.addEventListener("pointerleave", hide);
    // キーボードで送ったときも、丸を対象の中央へ置いて同じ見た目にする
    target.addEventListener("focus", () => {
      const rect = target.getBoundingClientRect();
      moveX(rect.left + rect.width / 2);
      moveY(rect.top + rect.height / 2);
      show();
    });
    target.addEventListener("blur", hide);
  });
}

同じ用途のインタラクション

カーソルをすべて見る

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

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