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

カーソル

横に並ぶ作品の上でカーソルが左右の矢印に変わり、押したまま引くと慣性でついてくる

デモ

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

仕様

用途
カーソル
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

横に並んだ作品の上では、小さな点のカーソルが、左右の矢印の入った丸いラベルに変わります。押し下げると丸がひとまわり縮んで矢印が内側へ寄り、引いた分だけ列がついてきて、離すとそのときの速さに応じて流れてから止まります。

列の位置は、リストの要素の scrollLeft で動かすことにしました。ブラウザの横スクロールをそのまま使っているので、指でのスワイプも、フォーカスを当てたときの矢印キーも、ふだんどおりに効きます。

引く速さを pointermove の中だけで測ると、手を止めてから離したときに困ります。止まっているあいだはイベントが来ないので直前の速さが残り、止めたはずの列が流れていってしまいます。毎フレーム位置の差を測り、動いていなければ速さを0にしています。引き終えた位置がリンクの上だと、離した瞬間にリンク先へ移動してしまうので、6px より大きく引いていたらクリックを止めます。

丸いラベルは直径92pxで、押し下げているあいだは70pxです。大きさは scale での拡大ではなく、幅と高さの値そのもので変えているので、中の矢印の線が太くなりません。要素の中心をポインタに合わせる指定も欠かせず、これがないと丸が大きくなるたびに右下へふくらんで見えます。

手応えを決めるのは慣性の時間です。いまは0.9秒かけて止めていて、長く取ると勢いよく滑る作品集らしい動きになり、短くすると指に素直についてきて、すっと止まります。

コード

HTML
<section class="gallery" aria-labelledby="gallery-title">
  <p class="gallery__label">Gallery</p>
  <h2 class="gallery__title" id="gallery-title">制作実績</h2>
  <p class="gallery__lead">ドラッグで移動できます</p>
  <!-- tabindex を付けると、フォーカスが入ったとき矢印キーでも横に送れる -->
  <ul class="gallery__strip" tabindex="0" aria-label="作品の一覧。左右に動かせます">
    <li>
      <a class="card" href="/work/seaside-hotel/">
        <span class="card__art" style="--from:#475C6A;--to:#93B2C9"></span>
        <span class="card__name">海辺のホテル</span>
        <span class="card__note">ブランドサイト/2026年</span>
      </a>
    </li>
    <li>
      <a class="card" href="/work/night-marche/">
        <span class="card__art" style="--from:#857BA5;--to:#AEA0CF"></span>
        <span class="card__name">夜のマルシェ</span>
        <span class="card__note">イベントの特設ページ/2026年</span>
      </a>
    </li>
    <li>
      <a class="card" href="/work/dune-lamp/">
        <span class="card__art" style="--from:#A98860;--to:#D9BA92"></span>
        <span class="card__name">砂丘ランプ</span>
        <span class="card__note">製品の紹介サイト/2025年</span>
      </a>
    </li>
    <li>
      <a class="card" href="/work/forest-lab/">
        <span class="card__art" style="--from:#475C6A;--to:#A2C49F"></span>
        <span class="card__name">森の研究所</span>
        <span class="card__note">採用サイト/2025年</span>
      </a>
    </li>
    <li>
      <a class="card" href="/work/rose-terrace/">
        <span class="card__art" style="--from:#BA7069;--to:#D99590"></span>
        <span class="card__name">ばらのテラス</span>
        <span class="card__note">飲食店のサイト/2025年</span>
      </a>
    </li>
  </ul>
</section>

<!-- 独自カーソル。中の矢印は左右 1 つずつ -->
<div class="drag-cursor" aria-hidden="true">
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5 L8 12 L15 19"/></svg>
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5 L16 12 L9 19"/></svg>
</div>

CSS
.gallery {
  --bg: #111111;
  --surface: #262626;
  --ink: #eeece6;
  --mute: #a8a6a0;
  --line: rgba(238, 236, 230, 0.14);
  box-sizing: border-box;
  padding: 96px 0 96px clamp(16px, 7.5vw, 96px);
  background: var(--bg);
  color: var(--ink);
}

.gallery__label {
  margin: 0;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  line-height: 1;
  text-transform: uppercase;
}

.gallery__title {
  margin: 16px 0 0;
  font-size: clamp(32px, 3.5vw, 44px);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.1;
}

.gallery__lead {
  margin: 16px 0 0;
  color: var(--mute);
  font-size: 14px;
  letter-spacing: 0.04em;
  line-height: 1.7;
  word-break: auto-phrase;
}

.gallery__strip {
  display: flex;
  gap: 24px;
  margin: 32px 0 0;
  padding: 0 clamp(16px, 7.5vw, 96px) 0 0;
  list-style: none;
  overflow-x: auto;
  scrollbar-width: thin;
  overscroll-behavior-x: contain;
}

.gallery__strip > li { flex: none; width: clamp(264px, 25vw, 320px); }

.card {
  display: block;
  overflow: hidden;
  border-radius: 4px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: inherit;
  text-decoration: none;
}

.gallery__strip:focus-visible,
.card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.card__art { display: block; height: 220px; background: linear-gradient(140deg, var(--from), var(--to)); }
.card__name { display: block; padding: 24px 24px 0; font-size: 20px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.3; }
.card__note { display: block; padding: 8px 24px 24px; color: var(--mute); font-size: 13px; letter-spacing: 0.06em; line-height: 1.7; }

/* 独自カーソル。大きさは JS が幅と高さで変える(拡大だと中の矢印まで伸びる) */
.drag-cursor {
  --dot: 14px;
  --ring: 92px;
  --grab: 70px;
  --spread: 26;
  --near: 19;
  position: fixed;
  left: 0;
  top: 0;
  z-index: 9999;
  display: none;
  width: var(--dot);
  height: var(--dot);
  border-radius: 50%;
  background: #eeece6;
  color: #111111;
  pointer-events: none;
}

.drag-cursor svg { position: absolute; left: 50%; top: 50%; width: 26px; height: 26px; margin: -13px 0 0 -13px; opacity: 0; }

/* JS が有効にしたときだけ表示する。ポインタの無い端末は本物のカーソルのまま */
.has-drag-cursor .drag-cursor { display: block; }
.has-drag-cursor .gallery,
.has-drag-cursor .gallery a { cursor: none; }

@media (prefers-reduced-motion: no-preference) {
  .gallery__strip { scroll-behavior: smooth; }
}

JavaScript
import { gsap } from "gsap";

const strip = document.querySelector(".gallery__strip");
const cursor = document.querySelector(".drag-cursor");
// タッチ端末にはポインタが無いので、独自カーソルは表示せず、横のスワイプに任せる
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (strip && cursor && canHover) {
  const style = getComputedStyle(cursor);
  const dot = style.getPropertyValue("--dot").trim();
  const ring = style.getPropertyValue("--ring").trim();
  const grab = style.getPropertyValue("--grab").trim();
  const spread = parseFloat(style.getPropertyValue("--spread"));
  const near = parseFloat(style.getPropertyValue("--near"));
  const arrows = cursor.querySelectorAll("svg");
  // 大きさを変えても位置がずれないよう、中心をポインタに合わせる
  gsap.set(cursor, { xPercent: -50, yPercent: -50 });
  document.documentElement.classList.add("has-drag-cursor");

  // 動きを減らす設定では遅れずに置く(quickTo は duration が 0 だと値が動かない)
  const follow = (prop) => (reduceMotion ? (value) => gsap.set(cursor, { [prop]: value }) : gsap.quickTo(cursor, prop, { duration: 0.16, ease: "power3" }));
  const moveX = follow("x");
  const moveY = follow("y");
  window.addEventListener("pointermove", (event) => {
    moveX(event.clientX);
    moveY(event.clientY);
  });

  // 大きさは scale ではなく幅と高さで変える(scale だと中の矢印まで伸びる)
  const shape = (size, gap, time) => {
    const duration = reduceMotion ? 0.05 : time;
    gsap.to(cursor, { width: size, height: size, duration, ease: "power3.out", overwrite: "auto" });
    gsap.to(arrows, { x: (i) => (i === 0 ? -gap : gap), opacity: gap === 0 ? 0 : 1, duration, ease: "power3.out", overwrite: "auto" });
  };

  let dragging = false;
  let startX = 0;
  let from = 0;
  let moved = 0;
  let last = 0;
  let speed = 0;
  let glide = null;

  strip.addEventListener("pointerenter", () => {
    if (!dragging) shape(ring, spread, 0.32);
  });
  strip.addEventListener("pointerleave", () => {
    if (!dragging) shape(dot, 0, 0.32);
  });
  strip.addEventListener("pointerdown", (event) => {
    dragging = true;
    startX = event.clientX;
    from = strip.scrollLeft;
    last = strip.scrollLeft;
    moved = 0;
    speed = 0;
    glide?.kill();
    strip.setPointerCapture(event.pointerId);
    shape(grab, near, 0.22);
  });
  strip.addEventListener("pointermove", (event) => {
    if (!dragging) return;
    moved = event.clientX - startX;
    strip.scrollLeft = from - moved;
  });
  // 速さは毎フレーム測る。ポインタが止まってもイベントは来ないので、止まったまま離すと前の速さで流れてしまう
  gsap.ticker.add((time, delta) => {
    if (!dragging) return;
    speed = ((strip.scrollLeft - last) / delta) * 1000;
    last = strip.scrollLeft;
  });

  const release = () => {
    if (!dragging) return;
    dragging = false;
    // 離したときの速さの 0.25 秒ぶんだけ、慣性で流して止める
    if (!reduceMotion) glide = gsap.to(strip, { scrollLeft: strip.scrollLeft + speed * 0.25, duration: 0.9, ease: "power3.out" });
    shape(ring, spread, 0.3);
  };
  strip.addEventListener("pointerup", release);
  strip.addEventListener("pointercancel", release);
  // ドラッグして離した所でリンクへ飛ばない。ドラッグの途中で画像がドラッグされるのも止める
  strip.addEventListener("click", (event) => {
    if (Math.abs(moved) > 6) event.preventDefault();
  });
  strip.addEventListener("dragstart", (event) => event.preventDefault());
}

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

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