デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 にすると、光の尾のような見た目になります。遊びのあるキャンペーンやポートフォリオ、イベントのサイトに向いています。
コード
<!-- 独自カーソルの点の列。飾りなので読み上げからは外し、<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 }));
}