デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
横に並んだ作品の上では、小さな点のカーソルが、左右の矢印の入った丸いラベルに変わります。押し下げると丸がひとまわり縮んで矢印が内側へ寄り、引いた分だけ列がついてきて、離すとそのときの速さに応じて流れてから止まります。
列の位置は、リストの要素の scrollLeft で動かすことにしました。ブラウザの横スクロールをそのまま使っているので、指でのスワイプも、フォーカスを当てたときの矢印キーも、ふだんどおりに効きます。
引く速さを pointermove の中だけで測ると、手を止めてから離したときに困ります。止まっているあいだはイベントが来ないので直前の速さが残り、止めたはずの列が流れていってしまいます。毎フレーム位置の差を測り、動いていなければ速さを0にしています。引き終えた位置がリンクの上だと、離した瞬間にリンク先へ移動してしまうので、6px より大きく引いていたらクリックを止めます。
丸いラベルは直径92pxで、押し下げているあいだは70pxです。大きさは scale での拡大ではなく、幅と高さの値そのもので変えているので、中の矢印の線が太くなりません。要素の中心をポインタに合わせる指定も欠かせず、これがないと丸が大きくなるたびに右下へふくらんで見えます。
手応えを決めるのは慣性の時間です。いまは0.9秒かけて止めていて、長く取ると勢いよく滑る作品集らしい動きになり、短くすると指に素直についてきて、すっと止まります。
コード
<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());
}