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

矢印のカーソルが進む向きに回り、止まると元の向きに戻る

デモ

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

仕様

用途
カーソル
動作
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 で隠し、指で触る端末ではつながずに本物のカーソルのままにします。キーボードで焦点を移すと、矢印がその項目の右下の角へ滑りながら進む向きに回り、着くと元の向きに戻ります。動きを減らす設定では矢印を回さず、元の向きのままポインタに付いてこさせます。

つまみは、止まっているときの向き、測る間隔、回す時間、戻るまでの待ち時間です。回す時間を短くするときびきびと、長くするとゆったり舵を切るような表情になります。速さで形が伸びるカーソルと違い、形は変えずに向きだけで動きを伝えるので、地図や旅行、物流のサービス、ゲームやポートフォリオなど、進む・導くことが主題のページに向いています。

コード

HTML
<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>

CSS
.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;
}

JavaScript
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) });
  });
}

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。