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

カーソルを速く動かすと円が進行方向に伸びる

デモ

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

仕様

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

しくみと調整

丸い独自カーソルを、動かす速さに応じて進む向きへ細長く伸ばし、止まると丸に戻します。毎コマ、前のコマからの移動量を経過時間で割って速さを出し、丸をポインタの位置に置いて、Math.atan2 で出した進む向きに回します。伸びは横の倍率を上げて縦の倍率を下げ、quickTo で 0.2 秒かけて追わせます。

速さ 3000px/秒で伸びきり、そのとき長さは 2.5 倍、太さは半分になります。GSAP の transform は回転のあとに拡大が並ぶので、横に伸ばすと、回した向き、つまり動く向きに伸びます。細い輪を 0.35 秒遅れで追わせると、形の変わらない輪との差で、丸の伸び縮みが引き立ちます。

速さを pointermove の中だけで測ると、止まったときにイベントが来ないので、伸びたまま残ります。gsap.ticker で毎コマ測り、動いていなければ速さ 0 として丸に戻します。ほぼ止まっている間は向きを変えません。1px の揺れでも向きを取り直すと、丸がくるくる回って落ち着きません。ページに入った直後の 1 回目の移動も、前の位置から飛ぶので速さに数えません。

本物のカーソルは cursor: none で隠しますが、隠すのは独自カーソルを出したときだけにし、指で触る端末では本物のカーソルのままにします。動きを減らす設定では伸ばさず、丸のまま付いてこさせます。キーボードで焦点を移すと、丸がその項目の右下へ滑り、進む向きに伸びてから丸に戻ります。

伸びきる速さを下げると、軽く動かしただけでよく伸び、長さの倍率を上げるとゴムのような表情になります。暗い地では丸を白にします。遅れて追う輪だけのカーソルと違い、速さで形が変わるのが主役なので、キャンペーンやモーションスタジオのヒーロー、遊びのあるポートフォリオに向いています。

コード

HTML
<section class="momentum-hero" aria-labelledby="momentum-hero-title">
  <h1 class="momentum-hero__title" id="momentum-hero-title">事業を前へ、<br>進めるデザイン。</h1>
  <p class="momentum-hero__lead">ここに説明文が入ります。</p>
  <a class="momentum-hero__button" href="/contact/">制作を相談する</a>
</section>

<!-- 独自カーソル。飾りなので読み上げからは外し、<body> の終わりに置く。輪を先に置くと、丸が輪の上に重なる -->
<span class="velocity-ring" aria-hidden="true"></span>
<span class="velocity-blob" aria-hidden="true"></span>

CSS
:root {
  --blob-size: 32px; /* 止まっているときの丸の直径 */
  --ring-size: 60px; /* 遅れて追う輪の直径 */
  --blob: #262626; /* 丸の色。暗い地なら白にする */
  --ring: rgba(38, 38, 38, 0.78); /* 輪の線 */
}

.momentum-hero {
  --paper: #f2f1ed;
  --ink: #262626;
  display: grid;
  align-content: end;
  justify-items: start;
  gap: 24px;
  min-height: 100vh;
  box-sizing: border-box;
  padding: 96px clamp(24px, 7.5vw, 96px) 64px;
  background: var(--paper);
  color: var(--ink);
}

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

.momentum-hero__lead {
  max-width: 30em;
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  word-break: auto-phrase;
}

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

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

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

.velocity-blob {
  width: var(--blob-size);
  height: var(--blob-size);
  background: var(--blob);
}

.velocity-ring {
  width: var(--ring-size);
  height: var(--ring-size);
  box-sizing: border-box;
  border: 1.5px solid var(--ring);
}

.has-velocity-cursor .velocity-blob,
.has-velocity-cursor .velocity-ring {
  display: block;
}

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

JavaScript
import { gsap } from "gsap";

const STRETCH = 1.5; // 伸びきったときの長さの増し分(直径に対する比)。1.5 で 2.5 倍
const SQUASH = 0.5; // 伸びきったときの太さの減り(同じく)。0.5 で半分
const FULL_SPEED = 3000; // この速さ(px/秒)で伸びきる
const blob = document.querySelector(".velocity-blob");
const ring = document.querySelector(".velocity-ring");
// 指で触る端末にはポインタが無いので、独自カーソルは出さず本物のカーソルのままにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (blob && ring && canHover) {
  document.documentElement.classList.add("has-velocity-cursor");
  // 中心をポインタに合わせる。最初に動かすまでは隠しておく
  gsap.set([blob, ring], { xPercent: -50, yPercent: -50, autoAlpha: 0 });
  const pointer = { x: 0, y: 0 };
  const prev = { x: 0, y: 0 };
  let shown = false;
  // 伸びは少し遅れて追わせる。GSAP の transform は rotate → scale の順なので、scaleX は回した向き(動く向き)に伸びる
  const stretch = gsap.quickTo(blob, "scaleX", { duration: 0.2, ease: "power3" });
  const squash = gsap.quickTo(blob, "scaleY", { duration: 0.2, ease: "power3" });
  // 動きを減らす設定では、輪も遅れずに付いてくる(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (value) => gsap.set(ring, { [prop]: value }) : gsap.quickTo(ring, prop, { duration: 0.35, ease: "power3" }));
  const ringX = follow("x");
  const ringY = follow("y");

  const moveTo = (x, y) => {
    pointer.x = x;
    pointer.y = y;
    if (shown) return;
    // 出した直後は、前の位置からの飛びを速さに数えない(一瞬伸びきってしまう)
    shown = true;
    prev.x = x;
    prev.y = y;
    gsap.set([blob, ring], { x, y, autoAlpha: 1 });
  };
  window.addEventListener("pointermove", (event) => moveTo(event.clientX, event.clientY));

  // ページの外へ出たら隠す(端に貼り付いたまま残らないように)
  document.documentElement.addEventListener("pointerleave", () => {
    shown = false;
    gsap.set([blob, ring], { autoAlpha: 0 });
  });

  // 毎コマ、前のコマからの移動量で速さを測る。pointermove の中だけで測ると、止まったときにイベントが来ず、伸びたまま残る
  gsap.ticker.add((time, deltaTime) => {
    if (!shown) return;
    const dt = Math.max(deltaTime, 1) / 1000;
    const vx = (pointer.x - prev.x) / dt;
    const vy = (pointer.y - prev.y) / dt;
    prev.x = pointer.x;
    prev.y = pointer.y;
    // 動きを減らす設定では伸ばさず、丸のまま付いてこさせる
    const s = reduceMotion ? 0 : Math.min(Math.hypot(vx, vy) / FULL_SPEED, 1);
    gsap.set(blob, { x: pointer.x, y: pointer.y });
    if (s > 0.05) gsap.set(blob, { rotation: (Math.atan2(vy, vx) * 180) / Math.PI }); // ほぼ止まっている間は向きを変えない(ちらつく)
    stretch(1 + STRETCH * s);
    squash(1 - SQUASH * s);
    ringX(pointer.x);
    ringY(pointer.y);
  });

  // キーボードで焦点を移したら、丸をその項目の右下の角へ滑らせる(動く向きに伸び、着くと丸に戻る)
  const glide = { x: 0, y: 0 };
  document.addEventListener("focusin", (event) => {
    if (!event.target.matches(":focus-visible")) return;
    const box = event.target.getBoundingClientRect();
    const x = box.right + 8;
    const y = box.bottom + 8;
    if (!shown) {
      moveTo(x, y); // まだ出ていなければ、その場に出す
      return;
    }
    glide.x = pointer.x;
    glide.y = pointer.y;
    gsap.to(glide, { x, y, duration: reduceMotion ? 0 : 0.5, ease: "power2.inOut", overwrite: "auto", onUpdate: () => moveTo(glide.x, glide.y) });
  });
}

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

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