GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

画像 スライダー css 9種 — GSAPなしでレスポンシブ対応

画像 スライダー css とは、写真の枠を1列に並べて矢印・ドット・指で1枚ずつ見せる部品のことです。GSAPなしで9つ組んであり、JavaScript が扱うのはインデックスの数字ひとつだけ、距離と色は calc() と transition が担います。下のグリッドで全部が今すぐ動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順の軸は2つです。01〜04 は画面を作る値が単純になる順(押す → 追従する → 回す → 重ねる)、05〜07 は入力が矢印からホイール・タイマー・指へ広がる順で、08・09 はスライダーの居場所(ダイアログの中、画面の幅)を変える組です。9つすべてがインデックスという1本の駆動軸で回っており、同じ画面をスクロール位置で解くCSSだけのスライダーカルーセルと、JavaScript 0行で回るCSSのみカルーセルがこの軸の反対側の隣人です。

01画面いっぱいのヒーロースライダー

写真4枚を1列につないだトラックが窓を1コマずつずらして見せる、ランディングページ冒頭のパネルで、矢印・ドット・キーボードが全部同じインデックスを1つ見ています。

画面いっぱいのヒーロースライダー
新しいタブで開く

01 · 画面いっぱいのヒーロースライダー — 計測 累積面積 72.751% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translate3daria-roledescriptiontransition

インデックスだけを書き換える移動関数

function go(n) {
  index = (n + total) % total;
  track.style.setProperty('--i', index);
  for (var k = 0; k < total; k++) {
    slides[k].setAttribute('aria-hidden', k === index ? 'false' : 'true');
    dots[k].setAttribute('aria-current', k === index ? 'true' : 'false');
  }
}

次の矢印を押すとインデックスが0から1へ進み、トラックが matrix(1, 0, 0, 1, 0, 0) からちょうど1コマぶんの matrix(1, 0, 0, 1, -420, 0) へ動き、4番目のドットを押せば3コマを一気に越えて -1260 の位置に立ちます。

距離の計算を calc に任せるトラック

.hero__track {
  display: flex;
  width: 100%;
  height: 100%;
  transform: translate3d(calc(-100% * var(--i, 0)), 0, 0);
  transition: transform $duration $easing;
  will-change: transform;
}

.hero__slide { position: relative; flex: 0 0 100%; height: 100%; }

各コマが flex: 0 0 100% で窓の幅をいっぱいに取るので、画面幅が変わっても掛け算がコマ幅を読み直します。測定では23コマ中20コマで動き、累積面積 72.751% という9つで2番目の広さでした。

02サムネイル帯と同期するスライダー

大きな写真を送ると下のサムネイル帯が選択中のコマを中央寄りへ寄せていき、サムネイルを押すと大きな写真がその枚へ跳ぶ、商品詳細の写真セットに付ける双方向パネルです。

サムネイル帯と同期するスライダー
新しいタブで開く

02 · サムネイル帯と同期するスライダー — 計測 累積面積 62.812% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scrollIntoViewaria-selectedtranslateX

双方向で合わせるサムネイル同期

function go(n) {
  index = (n + total) % total;
  track.style.setProperty('--i', index);
  for (var k = 0; k < total; k++) {
    slides[k].setAttribute('aria-hidden', k === index ? 'false' : 'true');
    thumbs[k].setAttribute('aria-selected', k === index ? 'true' : 'false');
  }
  // 本体 → サムネイル方向。block:'nearest' なので帯だけ動き、ページはその場にいる
  thumbs[index].scrollIntoView({ block: 'nearest', inline: 'center', behavior: soft ? 'smooth' : 'auto' });
}

5番目のサムネイルを押すと大きな写真のインデックスが0から4へ進んでトラックが -1680 まで押し出され、帯の scrollLeft も0から84へ追いかけて動きますが、矢印で一コマだけ送ったときは scrollLeft が0のままでした。

ステージ の列を釘付けにする1行

// ステージ の自動列は内容の max-content に広がる — サムネイル帯(496px)がそのまま列幅になって
// 480px の窓でも横スクロールバーが出た(実測 2026-09-14、demo.py ではみ出し)。列は minmax(0,1fr) で釘付けにする
.stage { grid-template-columns: minmax(0, 1fr); }

// ── サムネイル帯。横にスクロールするのはここだけで、押す主体は scrollIntoView
.gal__strip {
  display: flex;
  gap: $sp-2;
  padding: $sp-1;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
}

画面で横にスクロールするのはこの帯だけで、押すのも人ではなく scrollIntoView なので、触らなくても選択中のコマが中央寄りへ動きます(動いたコマ 21/23・累積面積 62.812%)。

03中央が手前に出る奥行きスライダー

選択中の表紙が正面に立ち、両隣が後ろへ寝るアルバム表紙選びのパネルで、ステージ は perspective をかけた3D 空間、カードごとに角度と大きさが付きます。

中央が手前に出る奥行きスライダー
新しいタブで開く

03 · 中央が手前に出る奥行きスライダー — 計測 累積面積 33.322% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

perspectiverotateYtransform-style

選択コマからの距離を数える関数

function go(n) {
  index = (n + total) % total;
  for (var k = 0; k < total; k++) {
    var d = k - index;
    var far = Math.abs(d);
    cards[k].style.setProperty('--d', d);
    cards[k].style.setProperty('--ad', far);
    cards[k].style.zIndex = String(total - far);
    cards[k].style.pointerEvents = far > 2 ? 'none' : 'auto';
    cards[k].setAttribute('aria-current', d === 0 ? 'true' : 'false');
    dots[k].setAttribute('aria-current', d === 0 ? 'true' : 'false');
  }
}

スクロール位置は読まず、符号つき距離 d と絶対値 --ad の2つの数字だけをカードに載せます。右から2コマ離れたカードを押すとインデックスが3から1へ下がり、カードの transform が matrix3d で座り直します(動いたコマ 22/23・累積面積 33.322%)。

perspective をかけたステージ とカード変換

// ── 奥行きが生まれるステージ。ここに perspective がないと rotateY は平らな横潰れにしか見えない
.cf__box {
  position: relative;
  width: 100%;
  height: clamp(96px, 46vh, 140px);
  perspective: 900px;
  perspective-origin: 50% 50%;
  overflow: hidden;
}
.cf__track {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
}

カードの横ずらし・回転・縮小はこの2つの数字の calc() で決まり、translate3d は #{$step} を掛け、rotateY は1コマあたり -34deg です。遠くなるほど暗くなるのは透明度ではなくカードに被せる墨の幕なので、オレンジのステージ が透けて濁りません。

04重ねて溶け合うフェードスライドショー

4枚が同じ1つの格子マスに重なって opacity だけが互いに逆へ動く、紹介セクションの背景向けスライドショーのパネルで、トラックの移動距離は0なので狭い画面でも横スクロールの入りようがありません。

重ねて溶け合うフェードスライドショー
新しいタブで開く

04 · 重ねて溶け合うフェードスライドショー — 計測 累積面積 48.004% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

opacityaria-hiddentransition

1つの格子マスに重ねる構造

.xf__view {
  position: relative; display: grid;
  height: clamp(104px, 38vw, 172px);
  border-radius: $r-card; overflow: hidden;
  background: $subject-ink;
}
.xf__shot {
  grid-area: 1 / 1; position: relative;
  opacity: 0;
  transition: opacity $duration $easing;
}
.xf__shot.is-on { opacity: 1; }

4枚全部が同じ格子マス 1 / 1 に乗っているのでステージ の transform は実測でも none のままで、移動距離が0だからコマが変わってもレイアウトは計算し直されません。

opacity 1つで場面を入れ替える関数

function show(next) {
  index = (next + shots.length) % shots.length;
  shots.forEach(function (shot, i) {
    var on = i === index;
    shot.classList.toggle('is-on', on);
    shot.setAttribute('aria-hidden', on ? 'false' : 'true');
  });
  dots.forEach(function (dot, i) {
    dot.setAttribute('aria-current', i === index ? 'true' : 'false');
  });
}

次の矢印を押すと点いたコマの opacity 並びが 0,1,0,0 から 0,0,1,0 へ入れ替わり、最初のドットを押せば0コマ目へ戻ります。画面から消えたコマはアクセシビリティツリーからも同じ瞬間に外れます(動いたコマ 20/23・累積面積 48.004%)。

05ホイールを一コマずつ受ける縦スライダー

トラックは上下にしか動かず、ホイールを1回転させるたびに正確に1枚だけ進む、縦に長い写真集のパネルで、窓は overflow hidden のスクロールコンテナーではありません。

ホイールを一コマずつ受ける縦スライダー
新しいタブで開く

05 · ホイールを一コマずつ受ける縦スライダー — 計測 累積面積 41.397% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

translateYwheelaria-orientation

1コマだけを受け取るホイールの錠前

// 錠前の時間内に続いて届く wheel イベントは飲み込まれる。慣性スクロールは1回転でも
// 数十個のイベントを続けて送るので、この扉がないと1回で最後まで走り抜ける
view.addEventListener('wheel', function (e) {
  e.preventDefault();
  if (locked) { return; }
  locked = true;
  setTimeout(function () { locked = false; }, LOCK);
  stopPreview();
  show(index + (e.deltaY > 0 ? 1 : -1));
}, { passive: false });

錠前の時間は420ms(LOCK)で、実測でも錠前に入った2回目のホイールが飲み込まれて選択インデックスは1から2へ1コマだけ動き、窓の overflowY は hidden なのでページのスクロールと混ざりません。

縦にしか揺れないトラック

.vw__track {
  --i: 0;
  display: flex; flex-direction: column; height: 100%;
  transform: translateY(calc(var(--i) * -100%));
  transition: transform $duration $easing;
  will-change: transform;
}
.vw__shot { position: relative; flex: 0 0 100%; }

各コマが flex: 0 0 100% なのでトラックの高さはステージ の高さと等しく、translateY(-100%) 1回がちょうど1枚ぶんです。トラック全体の高さを基準にすると4枚が一度に通り過ぎます(動いたコマ 14/23・累積面積 41.397%)。

06進捗バーと停止ボタンつき自動スライドショー

残り時間を減っていくバーで示し、停止ボタン1つでタイマーが止まる、店内案内画面向けのパネルで、各コマに1200ms ずつ留まります。

進捗バーと停止ボタンつき自動スライドショー
新しいタブで開く

06 · 進捗バーと停止ボタンつき自動スライドショー — 計測 累積面積 42.569% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

scaleXtransform-originvisibilitychange

残り時間を scaleX で描くフレーム

function frame(now) {
  var passed = Math.min(1, (now - startAt) / HOLD);
  fill.style.setProperty('--p', (1 - passed).toFixed(3));
  if (passed >= 1) { startAt = now; show(index + 1); }
  raf = requestAnimationFrame(frame);
}
function play() {
  if (playing) { return; }
  playing = true;
  startAt = performance.now() - (HOLD - remain);
  root.classList.remove('is-paused');
  toggle.setAttribute('aria-label', PAUSE_LABEL);
  raf = requestAnimationFrame(frame);
}

バーは幅ではなく scaleX 1つで減るので毎フレームのレイアウト計算はなく、transform-origin: left が抜けると中央から両側へ吸い込まれます。停止ボタンを押して1.6秒待ってもバーの matrix(0.139, 0, 0, 1, 0, 0) とトラックの -400 の位置はまったく動きませんでした。

タブを離れると止まるタイマー

// タブを離れている間にコマが勝手に進むと、戻ってきたときどこにいたか分からなくなる
document.addEventListener('visibilitychange', function () {
  if (document.hidden) { pause(); } else if (wanted) { play(); }
});
// 動きを減らせという設定なら自動再生は最初から始めない(WCAG 2.2.2)
if (wanted) { play(); } else {
  root.classList.add('is-paused');
  toggle.setAttribute('aria-label', PLAY_LABEL);
}

停止ボタンと visibilitychange が同じタイマーを見るので、タブを離れている間にコマが勝手に進むことはありません。ボタンのラベルは「自動再生を停止」と「自動再生を開始」を往復し(動いたコマ 23/23・累積面積 42.569%)、動きを減らせという設定では自動再生を始めず最初のコマに留まります。

07指を離した速度で残りを決めるドラッグスライダー

掴んでいる間はトラックが指にぴったり追従し、離した瞬間の速度が次のコマか元の位置かを分ける、スマホの写真送りのパネルです。

指を離した速度で残りを決めるドラッグスライダー
新しいタブで開く

07 · 指を離した速度で残りを決めるドラッグスライダー — 計測 累積面積 40.266% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerdownsetPointerCapturetranslate3d

指の位置がそのままトラックの位置

function onDown(e) {
  stopDemo(); measure();
  dragging = true; startX = e.clientX; lastX = e.clientX; lastT = e.timeStamp; velocity = 0;
  track.setPointerCapture(e.pointerId);
}
function onMove(e) {
  if (!dragging) { return; }
  var dt = e.timeStamp - lastT;
  if (dt > 0) { velocity = (e.clientX - lastX) / dt; }
  lastX = e.clientX; lastT = e.timeStamp;
  speed.textContent = Math.abs(velocity).toFixed(2) + ' px/ms';
  place(e.clientX - startX, false);
}

ドラッグ中は transition を切って指の座標がそのままトラックの座標になり、setPointerCapture が指がコマから外れても座標を掴んだままにします。トラックの touch-action: pan-y で横ドラッグはスライダーが受け、縦スクロールはブラウザーに残ります(実測 touch_action の値は pan-y)。

離した瞬間の速度で判定

// 離した瞬間の速度(px/ms)が先で、ゆっくり長く引いたときは移動距離(28%)が2番目の基準になる
function onUp(e) {
  if (!dragging) { return; }
  dragging = false;
  if (track.hasPointerCapture(e.pointerId)) { track.releasePointerCapture(e.pointerId); }
  var moved = e.clientX - startX;
  var carry = Math.abs(velocity) > 0.4 || Math.abs(moved) > width * 0.28;
  show(index + (carry && moved < 0 ? 1 : 0) - (carry && moved > 0 ? 1 : 0), true);
}

速いスワイプは70px のドラッグでも速度 1.04 px/ms で次のコマへ進み、遅く短いドラッグは40px・0.07 px/ms で2つの基準に届かず元の位置へ戻ります。ゆっくり長く200px 引けば速度は遅くても距離の基準を越え、次のコマの位置 matrix(1, 0, 0, 1, -640, 0) に立ちます(動いたコマ 19/23・累積面積 40.266%)。

08アルバム格子から開くライトボックススライダー

格子の1コマが showModal でダイアログを開き、その窓の中で写真がもう一度スライダーになる、行事のアルバム向けパネルで、窓を閉じずに左右へ次の写真を見て回ります。

アルバム格子から開くライトボックススライダー
新しいタブで開く

08 · アルバム格子から開くライトボックススライダー — 計測 累積面積 99.915% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

showModaldialoggrid-template-columns

格子のコマが窓を開き、フォーカスは戻ってくる

// 格子のコマが dialog を開く。開いた後も窓はそのまま残し、トラックだけが動くのがこの項目の軸
function open(at, from) {
  opener = from || cells[at];
  show(at);
  if (!lb.open) { lb.showModal(); }
}
function stopDemo() { ab.classList.remove('is-demo'); clearInterval(timer); }
cells.forEach(function (cell, i) {
  cell.addEventListener('click', function () { stopDemo(); open(i, cell); });
});

3コマ目を押すと窓が開いてカウンターは 3 / 6 で始まり、トラックは -568 の位置に立ちます。Esc で窓を閉じるとフォーカスは押した格子のコマへ戻り、実測でも「雨上がりの街の路地を拡大」ボタンに届くのを確認しました。

窓の中で回るトラック

function show(next) {
  index = (next + total) % total;
  lbtrack.style.transform = 'translateX(' + (-index * 100) + '%)';
  count.textContent = (index + 1) + ' / ' + total;
  [].slice.call(lbtrack.children).forEach(function (slide, i) {
    slide.setAttribute('aria-hidden', i === index ? 'false' : 'true');
  });
}

窓は top layer に浮く本物の dialog なのでステージ のスクロール高さには加わりません。次へボタンでカウンターが 4 / 6、矢印キーで 5 / 6 へ進んでも窓は開いたままです(動いたコマ 18/23・累積面積 99.915% で9つの中でいちばん広い)。

09幅に応じて見える枚数が変わるスライダー

matchMedia が幅を読んで1画面の枚数を1・2・3枚に決める、関連商品の並び向けパネルで、写真の枠は aspect-ratio に縛られているので、高さは画像そのものではなく幅から常に計算されます。

幅に応じて見える枚数が変わるスライダー
新しいタブで開く

09 · 幅に応じて見える枚数が変わるスライダー — 計測 累積面積 22.367% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

aspect-ratiomatchMediaflex-basis

幅の境界を見張る matchMedia と塗り直し

// 幅は matchMedia が読む。resize ごとに測り直すのと違い、境界を越えるときに1度だけ呼ばれる
var wide = window.matchMedia('(min-width: 640px)');
var mid = window.matchMedia('(min-width: 420px)');
function maxIndex() { return Math.max(0, total - per); }
function paint() {
  root.style.setProperty('--per', per);
  chip.textContent = 'perView ' + per;
  index = Math.min(index, maxIndex());
  track.style.transform = 'translate3d(' + (-index * (100 / per)) + '%, 0, 0)';
  dots.forEach(function (dot, i) {
    dot.hidden = i > maxIndex();
    dot.setAttribute('aria-current', i === index ? 'true' : 'false');
  });
}
function measure() {
  per = wide.matches ? 3 : (mid.matches ? 2 : 1);
  paint();
}

塗り直しは --per とチップの文字を一緒に変え、インデックスを新しい最大値の中へ押さえるのでドットの数も減ります。幅を読み直すのは640px・420px の境界を見張る matchMedia 2つだけで、実測でも320px 幅で flex-basis 100%、480px で 50%、900px で 33.3333% と変わりました。

幅の唯一の光源である --per

// 幅の唯一の光源は --per ひとつ。間隔は gap ではなくスライドの内側余白に — flex-basis の計算が揺れない
.rp__slide {
  box-sizing: border-box;
  flex-grow: 0; flex-shrink: 0;
  flex-basis: calc(100% / var(--per));
  padding-right: $sp-1;
}
.rp__ph {
  display: block; width: 100%; aspect-ratio: 16 / 9;
  border-radius: $r-sm;
}

1つの --per がコマ幅を決めるので1コマぶんの移動量(100% / per)も一緒に変わり、写真の枠は3つの幅すべてで aspect-ratio 16 / 9 をそのまま守り、高さは幅に比例して決まりました(動いたコマ 14/23・累積面積 22.367%)。

画像 スライダー javascript はどこまで自分で書くのか?

答えを先に書くと、状態の数字1つを守るコードまでです。9つの JavaScript を全部広げると、インデックスをモジュロで包み、矢印・ドット・キーボード・ポインターのイベントをその数字へ繋ぎ、例外として05のホイールの錠前・06のタイマー停止・07の離した瞬間の速度計算が付くだけです。距離・色・組の合わせは CSS の calc() と変数が計算するので、画像 スライダー html の構造も窓1つ・トラック1本・操作ボタンで終わります。手を出さない方がいい線も同じように明確です。慣性の物理を自分で積分したり仮想スクロールを作り直すのは07のように速度のしきい値 0.4 px/ms と距離のしきい値 28% の2つで判定を終える方がバグが少なく、自動送りは停止ボタンとタブ離脱の検知を並べて置くのが WCAG 2.2.2 の求め方です。受け取ったフォルダーを開けば9つがそのまま入っています。

どこで壊れるか — 落とし穴

制作中に実際にぶつかったのは次の3つです。

奥行きを translateZ で組むとクリックがトラックに漏れる

03 の最初の版は、後ろへ下がるカードに translateZ を使いました。perspective 空間の中で translateZ によって押し戻された要素は Chrome のヒットテストから外れるので、画面には見えているのに押すとカードではなくトラックがクリックを受け、elementFromPoint で隣のカードの位置を確かめると .cf__track が返ってきました。奥行きを rotateY と縮小で組み直して初めて遠いカードを押して中央へ呼べるようになり、操作の実測でも右から2コマ離れたカードのクリックでインデックスが3から1へ動きました。

サムネイル帯がステージ の列を勝手に広げた

02 のサムネイル帯は画面より広い内容を抱えていて、ステージ の自動列が内容の幅をそのまま追いかけて広がったため、デモの窓に横スクロールバーが出ました。列を minmax(0, 1fr) で釘付けにして初めてバーが消え、今は横スクロールが帯の中だけで生きています(5番目のサムネイルを押すと帯の scrollLeft が0から84へ動く)。

ホイール1回で4枚が一気に通り過ぎた

05 を初めて回したとき、ホイールを1回転させる操作に慣性スクロールが数十個の wheel イベントを続けて送り、錠前のない版はそれを全部1コマずつ受け入れて最後まで走りました。420ms の錠前の扉を立てた後の実測では、錠前に入った2回目のイベントが飲み込まれ、トラックは matrix(1, 0, 0, 1, 0, -156) から1枚ぶんの -312 だけ下りています。

この9つのデモの zip のパスワードは 7up3thqw で、展開すると各項目の index.html と style.scss の原本が丸ごと入っています。

3つのバリエーションとアクセシビリティ

値を3つ入れ替えるだけで、9つの印象が一度に変わります。

名前 書き換える値 変わる感じ
duration $dur-enter 520ms → 260ms コマが収まるまでの時間が半分になり、指を離した瞬間に次のコマがもう立っている印象になる
easing $ease-out → $ease-spring 止まるときもう1度押されて戻り、写真に重みがあるように見える
color $subject-blue → $stage-orange 選択中のドット・進捗バー・サムネイルの縁の強調色が一気に変わって、同じ配置が暖かいトーンになる

アクセシビリティは文書構造から生まれています。9つすべてに region と aria-roledescription="carousel" で名前を付け、画面に見えるコマだけが aria-hidden="false" のままで、ドットは aria-current・サムネイルは aria-selected で自分の位置を知らせます。矢印キーは自動送りだけの06を除く8つで1コマずつ動き(05は上下キー)、08 では Esc で窓を閉じてもフォーカスが押した格子のコマへ戻るのを実測で確認しました。prefers-reduced-motion: reduce では遷移の時間が0秒になり、06 は自動再生を最初から始めず最初のコマに留まります。文字のコントラストは青い面 #0a4fe8 の上のクリームの文字が 5.98:1、06 のコマ番号は #ffd23f の上で 12.63:1、05 の案内文は #17141a の上で 10.65:1 で、9つの画面の文字15か所がすべて AA を通りました。

隣人としては、画像 ビフォーアフター スライダーは同じポインターイベントで境界線を押す軸を、3D キューブ カルーセルは perspective を別の角度で使う軸を、画像ギャラリー 効果とポートフォリオ ギャラリー UIは格子で写真の枠を扱う軸を、それぞれ続けています。根拠となる文書は MDN translate3d、MDN Pointer events、MDN dialog、WAI のカルーセルパターン、MDN prefers-reduced-motion を見てください。

FAQ

画像 スライダー html のコードをそのまま別のページに貼っても動きますか

動きます。各デモのスクリプトは要素の id だけを見て動き、外部への依存が0なので、マークアップとスタイルを一緒に移せばそのまま始まります。1つのページに複数を置くときは、id がぶつからないよう項目ごとに名前を変える方が安全です。

写真の枠に本物の写真はどう入れますか

今の写真の枠はトークンのパレットで合成した場面のグラデーションです。グラデーションが入っている background-image に写真の URL を入れ、role="img" の要素を <img> に置き換えればよく、トラックの距離計算はコマ幅のパーセントでできているので写真が変わっても触る必要はありません。

スマホで横に引くとページまで一緒に動きませんか

07 のトラックには touch-action: pan-y が掛かっているので、横のドラッグはスライダーが受け、縦のスクロールはブラウザーに残ります(実測 touch_action の値は pan-y)。setPointerCapture が指がコマから外れても離す瞬間まで座標を掴んでいるので、最後まで引いて離しても判定はその瞬間の速度で行われます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。