デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- 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 つと、慣性に掛ける時間です。慣性を長くすると勢いよく滑る作品集らしい手応えになり、短くすると指に素直について止まります。作品や商品の横並び、写真の帯、記事のカルーセルに向いています。指で触る端末では独自カーソルを出さず、横のスワイプに任せます。
コード
<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>
.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; }
}
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());
}