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

カーソル

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

デモ

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

仕様

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

しくみと調整

動かした道筋に沿って、カーソルの後ろにドットの列が尾のように伸び、グレージュからローズカラー、ラベンダー、スレートカラーへと色を変えながら、先細りに薄れていきます。速く動かせば間隔が開き、ゆっくりなら詰まり、止めるとすべてのドットがカーソルの下に集まって見えなくなります。

gsap.ticker で毎フレーム、ポインタの位置を時刻と一緒に記録しておき、i 番目のドットを「i × 0.03秒前の位置」に置いています。尾の長さはおよそ0.5秒分で、動いた向きは読み取れて、先頭のカーソルを見失うほどには長くなりません。位置を「何フレーム前か」で決めると、画面の更新の速さで尾の長さが変わり、120Hz の画面では60Hz の半分になってしまいます。何秒前の位置かを前後の記録から補間して求めているので、どの画面でも同じ長さです。

各ドットをひとつ前のドットへ quickTo で追わせる、鎖のような作り方もあります。ただそれだと、角を曲がったときに後ろのドットが内側へ近道して、軌跡が丸まります。通った道筋をそのままなぞるために、記録を読む形を選びました。

列は画面に固定して pointer-events を none にし、下のリンクやボタンをそのまま押せるようにしています。尾は動きそのものなので、prefers-reduced-motion が有効なときは表示せず、本物のカーソルのままにしました。時間差を広げると尾は長く伸び、ドットを大きくして重なりを 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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。