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

カーソルの軌跡に点が残って消える

デモ

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

仕様

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

しくみと調整

カーソルの通った跡に点の列を残し、後ろの点ほど小さく薄くして消していきます。gsap.ticker で毎フレーム、ポインタの位置を時刻と一緒に記録しておき、点 i を「i × 0.03 秒前の位置」に置きます。速く動かすと点の間が開き、ゆっくり動かすと詰まり、止まると点はカーソルの下に集まって見えなくなります。

点は 18 個で、先頭の 26px から末尾の 25% の大きさまで小さくし、不透明度も末尾で 0.15 まで下げます。色は先頭の後ろから、グレージュ → くすみローズ → ラベンダーグレー → 墨のスレートと、後ろほど暗い色へ移します。時間差を 0.03 秒にすると跡は約 0.5 秒ぶんの長さになり、動いた向きが読めて、それでいて先頭のカーソルを見失わない長さに収まります。

何フレーム前かで点を置くと、画面の更新の速さで跡の長さが変わり、120Hz の画面では 60Hz の半分になります。記録に時刻を持たせ、何秒前の位置かを前後の記録の間で補間して読むと、どの画面でも同じ長さになります。各点をひとつ前の点へ quickTo で追わせる鎖の作り方もありますが、角で内側へ近道して丸まるので、軌跡をそのままなぞるなら記録を読む形が向いています。

点は画面に固定して pointer-events を none にし、下のリンクやボタンを押せるようにします。本物のカーソルは点の列を出したときだけ隠し、指で触る端末では出しません。跡は動きそのものなので、動きを減らす設定でも出さず、本物のカーソルのままにします。ページの外へ出たら点の列を消し、端に残らないようにします。

つまみは、点の数(span の数)、時間差、先頭の大きさ、色の並びです。時間差を広げると尾が長く伸び、点を大きくして重なりを mix-blend-mode の screen にすると、光の尾のような見た目になります。遊びのあるキャンペーンやポートフォリオ、イベントのサイトに向いています。

コード

HTML
<!-- 独自カーソルの点の列。飾りなので読み上げからは外し、<body> の終わりに置く。点の数は span の数(最後の span が先頭の点) -->
<div class="cursor-trail" aria-hidden="true">
  <span></span><span></span><span></span><span></span><span></span><span></span>
  <span></span><span></span><span></span><span></span><span></span><span></span>
  <span></span><span></span><span></span><span></span><span></span><span></span>
</div>

CSS
.cursor-trail {
  --trail-head: 26px;   /* 先頭の点(カーソル)の直径 */
  --trail-tail: 0.25;   /* 最後の点の大きさ(先頭に対する比) */
  --trail-fade: 0.85;   /* 最後の点で下げる不透明度 */
  --trail-ink: #eeece6; /* 先頭の点 */
  /* 先頭の後ろから末尾へ、この順に色を移す */
  --trail-1: #d2b999;
  --trail-2: #d29b97;
  --trail-3: #b0a5ca;
  --trail-4: #788b97;
  /* JS がつないだときだけ出す。指で触る端末やスクリプトが動かないときは本物のカーソルのまま */
  display: none;
}

/* --k は JS が入れる点の番号の比(0 = 先頭、1 = 末尾)。後ろの点ほど小さく薄い */
.cursor-trail span {
  --size: calc(var(--trail-head) * (1 - (1 - var(--trail-tail)) * var(--k, 0)));
  position: fixed;
  left: 0;
  top: 0;
  z-index: 9999;
  width: var(--size);
  height: var(--size);
  /* 中心をポインタに合わせる。transform は JS が位置に使う */
  margin: calc(var(--size) / -2) 0 0 calc(var(--size) / -2);
  border-radius: 50%;
  background: var(--color, var(--trail-ink));
  opacity: calc(1 - var(--trail-fade) * var(--k, 0));
  /* 下のリンクやボタンを押せるようにする */
  pointer-events: none;
}

.has-cursor-trail .cursor-trail {
  display: block;
}

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

JavaScript
import { gsap } from "gsap";

const GAP = 0.03; // 点どうしの時間差(秒)。大きくすると跡が長く伸びる
const trail = document.querySelector(".cursor-trail");
const html = document.documentElement;
// 指で触る端末にはポインタが無いので、本物のカーソルのままにする。跡は動きそのものなので、動きを減らす設定でも出さない
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (trail && canHover && !reduceMotion) {
  // 後ろの span ほど上に重なるので、最後の span を先頭(カーソル)にする
  const dots = [...trail.children].reverse();
  const css = getComputedStyle(trail);
  const tint = gsap.utils.interpolate(["--trail-1", "--trail-2", "--trail-3", "--trail-4"].map((name) => css.getPropertyValue(name).trim()));
  dots.forEach((dot, i) => {
    const k = i / (dots.length - 1);
    dot.style.setProperty("--k", k);
    if (i > 0) dot.style.setProperty("--color", tint(k));
  });

  const span = (dots.length - 1) * GAP; // いちばん後ろの点が見ている過去(秒)
  const history = []; // ポインタの位置 { time, x, y } の新しい順
  let pointer = null;

  // at 秒の位置。前後の記録の間を補間する(フレームの数で数えると、画面の更新の速さで跡の長さが変わる)
  const positionAt = (at) => {
    const j = history.findIndex((h) => h.time <= at);
    if (j === 0) return history[0];
    if (j < 0) return history[history.length - 1];
    const [a, b] = [history[j], history[j - 1]];
    const k = (at - a.time) / (b.time - a.time);
    return { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k };
  };

  // 毎フレーム、今の位置を時刻と一緒に記録し、点 i を i × GAP 秒前の位置に置く。止まると点はカーソルの下に集まる
  gsap.ticker.add((time) => {
    if (!pointer) return;
    history.unshift({ time, ...pointer });
    while (history.length > 2 && history[history.length - 2].time <= time - span) history.pop();
    dots.forEach((dot, i) => {
      const { x, y } = positionAt(time - i * GAP);
      dot.style.transform = `translate(${x}px, ${y}px)`;
    });
  });

  window.addEventListener("pointermove", (event) => {
    if (event.pointerType === "touch") return;
    pointer = { x: event.clientX, y: event.clientY };
    html.classList.add("has-cursor-trail"); // 位置がわかってから出す(左上に点が残らない)
  });

  // ページの外へ出たら消す(端に貼り付いたまま残らないように)
  html.addEventListener("pointerleave", () => gsap.to(trail, { autoAlpha: 0, duration: 0.2 }));
  html.addEventListener("pointerenter", () => gsap.to(trail, { autoAlpha: 1, duration: 0.2 }));
}

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

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