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

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

デモ

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

仕様

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

しくみと調整

作品を横に並べた入れ物の上に入ると、小さな点のカーソルが左右の矢印を入れた丸いラベルに変わります。押し下げると輪がひとまわり縮んで矢印が内側に寄り、引いたぶんだけ列が指についてきます。離すと、そのときの速さのぶんだけ列が慣性で流れて止まります。列の位置は入れ物の scrollLeft で動かすので、指のスワイプも、焦点を当てたときの矢印キーも、ブラウザの機能のまま効きます。

点は直径 14px、ラベルの丸は 92px、押し下げているあいだは 70px です。矢印は中心から 26px 離し、押すと 19px まで内側へ寄ります。丸がポインタに追いつく時間は 0.16 秒と短くして、点でいるあいだは遅れて見えないようにしています。慣性は、離したときの速さの 0.25 秒ぶんだけ先へ流し、0.9 秒かけて止めます。

大きさは拡大ではなく、幅と高さの数値そのものを動かします。拡大で合わせると、中の矢印の線まで太く伸びてしまうためです。中心をポインタに合わせる指定も欠かせません。これが無いと、丸が大きくなるたびに右下へふくらんで見えます。

引く速さは、pointermove の中だけで測ると当てになりません。指を止めたままではイベントが来ないので、止めて離しても直前の速さで流れてしまいます。毎コマ位置の差を測り、動いていなければ速さ 0 とします。引いたあとに指を離した所がリンクだと、そのまま移動してしまうので、6px より大きく引いていたらクリックを止めてください。

つまみは、丸の直径 3 つと、慣性に掛ける時間です。慣性を長くすると勢いよく滑る作品集らしい手応えになり、短くすると指に素直について止まります。作品や商品の横並び、写真の帯、記事のカルーセルに向いています。指で触る端末では独自カーソルを出さず、横のスワイプに任せます。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。