デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 で隠しますが、隠すのは独自カーソルを出したときだけにし、指で触る端末では本物のカーソルのままにします。動きを減らす設定では伸ばさず、丸のまま付いてこさせます。キーボードで焦点を移すと、丸がその項目の右下へ滑り、進む向きに伸びてから丸に戻ります。
伸びきる速さを下げると、軽く動かしただけでよく伸び、長さの倍率を上げるとゴムのような表情になります。暗い地では丸を白にします。遅れて追う輪だけのカーソルと違い、速さで形が変わるのが主役なので、キャンペーンやモーションスタジオのヒーロー、遊びのあるポートフォリオに向いています。
コード
<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>
: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;
}
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) });
});
}