デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
矢印の独自カーソルを、先端がポインタの位置に来るように置き、先端を軸に回します。pointermove のたびに、前に向きを測った所から 6px 以上動いていれば、その 2 点を結ぶ向きを Math.atan2 で出し、quickTo で 0.3 秒かけて回します。止まって 0.18 秒たつと、ふつうのカーソルと同じ左上(−120°)へ戻します。
角度は、いちばん近い回り方で補間します。目標の角度をそのまま渡すと、右上の 359° から右下の 1° へ向けたとき、358° 逆に回って、ほぼ一周してしまいます。今の角度に、目標との差を gsap.utils.wrap で −180〜180° に折り返した量を足して渡すと、2° だけ回ります。この足し方なら、円を描き続けても同じ向きへ回り続けます。
向きを 1 回ごとの pointermove の移動量で測ると、1〜2px の揺れで向きがちらつきます。一定の距離(6px)動くごとに測ると、ゆっくり動かしても落ち着きます。止まってから戻るまでの 0.18 秒は、向きを変えようと一瞬止めたときに戻りかけないための間です。delayedCall を動くたびに restart して待ち直します。
本物のカーソルは cursor: none で隠し、指で触る端末ではつながずに本物のカーソルのままにします。キーボードで焦点を移すと、矢印がその項目の右下の角へ滑りながら進む向きに回り、着くと元の向きに戻ります。動きを減らす設定では矢印を回さず、元の向きのままポインタに付いてこさせます。
つまみは、止まっているときの向き、測る間隔、回す時間、戻るまでの待ち時間です。回す時間を短くするときびきびと、長くするとゆったり舵を切るような表情になります。速さで形が伸びるカーソルと違い、形は変えずに向きだけで動きを伝えるので、地図や旅行、物流のサービス、ゲームやポートフォリオなど、進む・導くことが主題のページに向いています。
コード
<section class="way-hero" aria-labelledby="way-hero-title">
<p class="way-hero__eyebrow">Wayfinding</p>
<h1 class="way-hero__title" id="way-hero-title">次の旅は、<br>どこへ行こう。</h1>
<p class="way-hero__lead">ここに説明文が入ります。</p>
<a class="way-hero__button" href="/start/">はじめる</a>
</section>
<!-- 独自カーソル。大きさ 0 の箱の原点が矢印の先端(ポインタの位置)で、先端を軸に回る -->
<div class="arrow-cursor" aria-hidden="true">
<svg viewBox="-32 -16 36 32" width="36" height="32" focusable="false">
<path d="M0 0-28-12-21 0-28 12Z" />
</svg>
</div>
.way-hero {
--paper: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
display: grid;
align-content: center;
justify-items: start;
gap: 24px;
min-height: 100vh;
box-sizing: border-box;
padding: 96px clamp(24px, 7.5vw, 96px);
background: var(--paper);
color: var(--ink);
}
.way-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.way-hero__title {
margin: 0;
font-size: clamp(44px, 7vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}
.way-hero__lead {
margin: 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
word-break: auto-phrase;
}
.way-hero__button {
display: inline-flex;
margin-top: 16px;
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;
}
.way-hero__button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
}
/* 矢印の先端が箱の原点に来るように SVG を置く(viewBox の -32 -16 の分だけ左上へ) */
.arrow-cursor {
--arrow: #262626;
--arrow-edge: #ffffff; /* 暗い所の上でも形が見えるよう、白い縁を付ける */
position: fixed;
left: 0;
top: 0;
z-index: 9999;
pointer-events: none;
visibility: hidden;
}
.arrow-cursor svg {
position: absolute;
left: -32px;
top: -16px;
overflow: visible;
}
.arrow-cursor path {
fill: var(--arrow);
stroke: var(--arrow-edge);
stroke-width: 2;
stroke-linejoin: round;
}
/* JS がつないだときだけ、本物のカーソルを隠す。ポインタの無い端末では本物のカーソルのまま */
.has-arrow-cursor,
.has-arrow-cursor a,
.has-arrow-cursor button {
cursor: none;
}
import { gsap } from "gsap";
const REST = -120; // 止まっているときの向き(度。0 が右、−90 が上。ふつうのカーソルと同じ左上)
const STEP = 6; // 向きを測る間隔(px)。小さな揺れで向きがちらつかないよう、これだけ動くごとに測る
const HOLD = 0.18; // 止まってから元の向きへ戻り始めるまで(秒)
const cursor = document.querySelector(".arrow-cursor");
// 指で触る端末にはポインタが無いので、独自カーソルは出さず本物のカーソルのままにする
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (cursor && canHover) {
document.documentElement.classList.add("has-arrow-cursor");
gsap.set(cursor, { rotation: REST, autoAlpha: 0 });
const turn = gsap.quickTo(cursor, "rotation", { duration: 0.3, ease: "power3" });
// 今の角度から、いちばん近い回り方で目標の向きへ回す(359° → 1° で一周しない)
const aim = (deg) => {
if (reduceMotion) return; // 動きを減らす設定では回さず、元の向きのまま動かす
const now = gsap.getProperty(cursor, "rotation");
turn(now + gsap.utils.wrap(-180, 180, deg - now));
};
const idle = gsap.delayedCall(HOLD, () => aim(REST)).pause();
let anchor = null; // 前に向きを測った位置
const moveTo = (x, y) => {
gsap.set(cursor, { x, y, autoAlpha: 1 });
anchor ??= { x, y };
if (Math.hypot(x - anchor.x, y - anchor.y) >= STEP) {
aim((Math.atan2(y - anchor.y, x - anchor.x) * 180) / Math.PI);
anchor = { x, y };
}
idle.restart(true); // 動くたびに待ち直す。止まって HOLD 秒たったら元の向きへ
};
window.addEventListener("pointermove", (event) => moveTo(event.clientX, event.clientY));
// ページの外へ出たら隠す。戻ったら、その位置から測り直す
document.documentElement.addEventListener("pointerleave", () => {
gsap.set(cursor, { autoAlpha: 0 });
anchor = null;
});
// キーボードで焦点を移したら、矢印をその項目の右下の角へ滑らせる(進む向きに回り、着いたら元の向きへ)
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 - 12;
const y = box.bottom - 8;
if (gsap.getProperty(cursor, "opacity") === 0) {
anchor = null;
moveTo(x, y); // まだ出ていなければ、その場に出す
return;
}
glide.x = gsap.getProperty(cursor, "x");
glide.y = gsap.getProperty(cursor, "y");
gsap.to(glide, { x, y, duration: reduceMotion ? 0 : 0.5, ease: "power2.inOut", overwrite: "auto", onUpdate: () => moveTo(glide.x, glide.y) });
});
}