デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 にすると、光の尾のような見え方になります。
コード
<!-- 独自カーソルの点の列。飾りなのでスクリーンリーダーからは隠し、<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>
.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;
}
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 }));
}