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

カルーセル スライド 9種 — 慣性ドラッグから3D遠近切替まで

カルーセル スライド9種のうち、1つはカードが切り替わるたびに慣性で少し滑ってから止まり、1つは端に触れるたびにゴムのように弾んで戻り、1つはカードが3D遠近で曲がりながら横へ通り過ぎます。

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

9つ並べておくと、画像 スライダー デザインとして実際に必要な幅を、同じ発想を繰り返さずに一通り押さえられます。3つはポインターの速度を物理ループへ読み込み、3つはカードごと・フレームごとに3D寄りの値を計算し、残る3つはその境界に立つ存在です — 継ぎ目のないループ、常に中央へ寄るサムネイル帯、タイマー駆動の自動再生です。以下の並び順は、最も手を動かす操作性の高いパターン(ドラッグ慣性)から、最も動きが控えめなパターン(クロスフェード)へと進みます。

デモ

ドラッグを離すと慣性でさらに滑るカルーセル
新しいタブで開く

01 · ドラッグを離すと慣性でさらに滑るカルーセル — 計測 累積面積 27.174% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

onPointerMoverequestAnimationFramereleaseVelocity

9つの遷移の解剖 — どこで計算の一層が回るか

各パターンは、ふつうのCSS transformの上にもう一層の計算を足しており、その一層こそが、ありふれた回転動作を9つのはっきり違う遷移に変えています。九分割した面積・強度・動いたフレーム数は、その一層が画面のどれだけを実際に動かしているかを示す実測値です。

# パターン 累積面積 平均強度 動いたフレーム
01 ドラッグ慣性 27.2% 174.3 23/23
02 ゴムの跳ね返り 34.8% 132.2 23/23
03 3D遠近カバーフロー 19.7% 76.6 22/23
04 無限ループ 34.8% 145.9 9/23
05 自動再生とホバー停止 34.8% 108.8 20/23
06 サムネイル同期 33.9% 55.5 22/23
07 スナップポイント 34.8% 129.1 18/23
08 縦型スタック 40.4% 84.2 18/23
09 クロスフェード 34.8% 58.0 22/23

面積が一番小さいのは03のカバーフローです。角度がついたカードの多くが画面の端で小さく縮んでいるため、動いている領域自体が狭いのであって、計算が軽いわけではありません。動いたフレームが9/23と際立って少ないのは04の無限ループで、これは境界を越えたときだけwrapIfNeeded()が発火する構造上、自然な回数です。

パターン別コード

9つそれぞれの核になる関数を、実際に動いているソースからそのまま切り出しています。

01ドラッグを離すと慣性でさらに滑るカルーセル

カードを横にドラッグして手を離すと、離した瞬間の速度のままカードが滑り続けて徐々に減速し、強く動かすほど遠くまで進みます。pushSample()が直近のポインター位置を一定の時間窓で記録し、releaseVelocity()がその窓から指を離した瞬間の初速を割り出します。その一つの数値がsettle()の中で毎フレーム減衰し、十分小さくなった時点で止まります。

ドラッグを離すと慣性でさらに滑るカルーセル
新しいタブで開く

01 · ドラッグを離すと慣性でさらに滑るカルーセル — 計測 累積面積 27.174% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

onPointerMoverequestAnimationFramereleaseVelocity

タッチやマウスのドラッグでめくる商品画像カルーセルに向いています。

  function settle() {
    var last = performance.now();
    function frame(now) {
      var dt = Math.min(40, now - last);
      last = now;
      pos = clamp(pos + velocity * dt, minX, maxX);
      var decay = Math.pow(1 - MOMENTUM_FRICTION, dt / STEP_MS);
      velocity *= decay;
      if (pos === minX || pos === maxX) velocity = 0;
      paint();
      if (Math.abs(velocity) > VELOCITY_STOP) {
        settleFrame = requestAnimationFrame(frame);
      } else {
        velocity = 0;
        settleFrame = null;
      }
    }
    settleFrame = requestAnimationFrame(frame);
  }

02端に触れるとゴムのように弾んで戻るカルーセル

最初や最後のカードでさらに押すと、カードが少し伸びるように付いてきて、手を離した瞬間ゴムのように弾んで元の位置へ戻ります。rawToVisual()が境界を越えた距離をrubber()という曲線で圧縮するため引っ張るほど抵抗が強く感じられ、手を離すとspringBackIfNeeded()が力と減衰のループでカードを引き戻します。スマートフォンのホーム画面を端まで送ったときに返ってくる、あの手応えと同じ仕組みです。

端に触れるとゴムのように弾んで戻るカルーセル
新しいタブで開く

02 · 端に触れるとゴムのように弾んで戻るカルーセル — 計測 累積面積 34.824% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

rawToVisualspringBackIfNeededonPointerMove

これ以上ページがないことを指先ですぐ伝えるギャラリーに向いています。

  // スプリング復帰 — pos が境界を越えたままのときだけ、目標(近い方の境界)まで力と減衰で引き戻す。
  function springBackIfNeeded() {
    var target = pos > maxX ? maxX : (pos < minX ? minX : null);
    if (target === null) return;
    var velocity = 0;
    function frame() {
      var force = (target - pos) * SPRING_STIFFNESS;
      velocity = (velocity + force) * SPRING_DAMPING;
      pos += velocity;
      paint();
      if (Math.abs(velocity) > SPRING_STOP || Math.abs(target - pos) > SPRING_STOP) {
        springFrame = requestAnimationFrame(frame);
      } else {
        pos = target;
        paint();
        springFrame = null;
      }
    }
    springFrame = requestAnimationFrame(frame);
  }

03カードが3D遠近で曲がりながら通り過ぎるカルーセル

中央のカードは正面を向いて大きく立ち、両脇のカードは角度が傾いたまま小さく奥へ下がるため、カードの列全体が緩やかな曲線を描いて回ります。render()はドラッグした距離からカードごとの角度・奥行き・拡大率を毎フレーム計算し直しており、これは角度の計算を持たないscroll-snapだけのカバーフローとは異なる方式です。この毎フレームの計算があるからこそ、ポートフォリオやアルバム表紙を見渡す画面が段階的ではなく連続的に感じられます。

カードが3D遠近で曲がりながら通り過ぎるカルーセル
新しいタブで開く

03 · カードが3D遠近で曲がりながら通り過ぎるカルーセル — 計測 累積面積 19.651% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

animateIndexTotranslateZrotateY

ポートフォリオ・アルバム表紙を見渡す画面に向いています。

  function render() {
    for (var n = 0; n < cards.length; n++) {
      var dist = n - index;
      var absDist = Math.abs(dist);
      var angle = clamp(dist * ANGLE_STEP, -MAX_ANGLE, MAX_ANGLE);
      var z = -absDist * DEPTH_STEP;
      var scale = Math.max(SCALE_FLOOR, 1 - absDist * SCALE_STEP);
      var opacity = Math.max(OPACITY_FLOOR, 1 - absDist * OPACITY_STEP);
      var x = dist * 58;
      cards[n].style.transform = 'translateX(' + x.toFixed(1) + 'px) translateZ(' + z.toFixed(1) + 'px) rotateY(' + angle.toFixed(1) + 'deg) scale(' + scale.toFixed(3) + ')';
      cards[n].style.opacity = opacity.toFixed(2);
      cards[n].style.zIndex = String(100 - Math.round(absDist * 10));
    }
    var shown = clamp(Math.round(index), 0, TOTAL - 1);
    readout.textContent = LABEL_PREFIX + (shown + 1) + LABEL_SEP + TOTAL;
  }

04最後まで行くと最初へつながる無限ループカルーセル

最後のカードのすぐ後ろに最初のカードの複製をあらかじめ並べておき、その境界を越えた瞬間にwrapIfNeeded()が見た目には分からないよう実際のカードの位置へそっと戻します。この複製とモジュロ演算を組み合わせた仕組みは、クローンとモジュロ折り返しを併せ持つ点で他のどのカルーセルとも重ならず、ブランドロゴの帯を1枚目に戻る瞬間を見せずに回し続けられる理由です。onTransitionEnd()が、CSSのtransitionが終わったちょうどその瞬間にこの見えないリセットを合わせています。

最後まで行くと最初へつながる無限ループカルーセル
新しいタブで開く

04 · 最後まで行くと最初へつながる無限ループカルーセル — 計測 累積面積 34.824% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

wrapIfNeededonTransitionEndrealIndex

ブランドロゴを途切れず流し続けるバーに向いています。

  function paint(withTransition) {
    track.style.transition = withTransition ? 'transform ' + TRANSITION_MS + 'ms cubic-bezier(.22,.61,.36,1)' : 'none';
    track.style.transform = 'translate3d(' + (-pos * STEP).toFixed(1) + 'px,0,0)';
    readout.textContent = LABEL_PREFIX + (realIndex() + 1) + LABEL_SEP + TOTAL;
  }

  // モジュロ折り返し — 位置が複製区間(先頭より前、または末尾より後)に入ったら、遷移が終わったその瞬間に
  // transition を切り、同じ絵の実カードへ pos だけ瞬時に移動させる(画面上は分からない)。
  function wrapIfNeeded() {
    if (pos < CLONE_COUNT) {
      pos += TOTAL;
      paint(false);
      void track.offsetWidth; // 強制リフローで transition:none を確定させる
    } else if (pos >= CLONE_COUNT + TOTAL) {
      pos -= TOTAL;
      paint(false);
      void track.offsetWidth;
    }
  }

  function onTransitionEnd() {
    transitioning = false;
    wrapIfNeeded();
  }
  track.addEventListener('transitionend', onTransitionEnd);

05自動で切り替わりマウスを乗せると止まるカルーセル

一定の間隔ごとにカルーセルが自動で次のカードへ進み、その時間の分だけ細い進捗バーが満ちていき、カード上にカーソルを置くとバーはその位置のまま止まります。tick()はplayingが真でカーソルが乗っていない間だけ経過時間をelapsedに加えるため、再開したときはゼロからではなく止まっていた地点から続けて満ちていきます。誰も見ていなくても回り続けるメインバナーのような領域に向いたパターンです。

自動で切り替わりマウスを乗せると止まるカルーセル
新しいタブで開く

05 · 自動で切り替わりマウスを乗せると止まるカルーセル — 計測 累積面積 34.826% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

showSlideonMouseEnterprogressFill
  function advance() {
    current = (current + 1) % TOTAL;
    showSlide(current);
    elapsed = 0;
  }
  function setPlayingUI() {
    playBtn.setAttribute('aria-pressed', playing ? 'false' : 'true');
    playBtn.setAttribute('aria-label', playing ? PAUSE_LABEL : RESUME_LABEL);
    iconPause.classList.toggle('isHidden', !playing);
    iconPlay.classList.toggle('isHidden', playing);
  }

  // 計算の一層 — 毎フレーム dt を elapsed に加えるが、hovering(または !playing)のときは加えない。
  // そのため再開したときは最初からではなく「止まっていたその地点」から続けて満ちていく(残り時間を保存)。
  function tick(now) {
    if (!lastTick) lastTick = now;
    var dt = Math.min(48, now - lastTick);
    lastTick = now;
    if (playing && !hovering && !reduce) {
      elapsed += dt;
      if (elapsed >= AUTOPLAY_MS) { advance(); }
    }
    progressFill.style.width = (Math.min(1, elapsed / AUTOPLAY_MS) * 100).toFixed(1) + '%';
    rafId = requestAnimationFrame(tick);
  }

06下のサムネイルが現在のカードに合わせて明るくなるカルーセル

大きな画面のカードが切り替わるたびに、下の小さなサムネイル列で該当する枠だけ縁が明るくなり、残りは薄く沈むため、今何番目かが一目でわかります。computeTargetOffset()が選ばれたサムネイルを帯の中央に置くために必要な移動量を計算し、centerActiveThumb()がその移動量まで毎フレームイージングで近づけていきます — 単にactiveクラスを切り替えるだけの実装にはない「常に中央寄せ」という一層です。この一層があるおかげで、商品ギャラリーの奥深くまで進んでもサムネイルの列が読みやすいままです。

下のサムネイルが現在のカードに合わせて明るくなるカルーセル
新しいタブで開く

06 · 下のサムネイルが現在のカードに合わせて明るくなるカルーセル — 計測 累積面積 33.855% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

computeTargetOffsetcenterActiveThumbthumbCenter

商品詳細ページの大きな画像とサムネイル一覧に向いています。

  function centerActiveThumb() {
    targetOffset = computeTargetOffset();
    stopCenter();
    function frame() {
      offset += (targetOffset - offset) * CENTER_EASE;
      thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
      if (Math.abs(targetOffset - offset) > CENTER_STOP) {
        centerFrame = requestAnimationFrame(frame);
      } else {
        offset = targetOffset;
        thumbTrack.style.transform = 'translate3d(' + offset.toFixed(1) + 'px,0,0)';
        centerFrame = null;
      }
    }
    centerFrame = requestAnimationFrame(frame);
  }

07手を離すと常にカード1枚にぴったり止まるカルーセル

カードとカードの間の中途半端な位置で手を離しても、最も近いカードの境界まで滑らかに動いて止まるため、常にカード1枚が画面にぴったり揃います。releaseVelocity()がドラッグの速さと方向を読み取り、judgeSnapIndex()がその方向をもとに、離れようとしていたカードと向かおうとしていたカードのどちらへ収束させるかを判定します。カードが半端に見えてはいけない整列型のスライドショーには、この方向を考慮したスナップが欠かせません。

手を離すと常にカード1枚にぴったり止まるカルーセル
新しいタブで開く

07 · 手を離すと常にカード1枚にぴったり止まるカルーセル — 計測 累積面積 34.805% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

judgeSnapIndexreleaseVelocityanimateTo
  function judgeSnapIndex(rawPos, velocity) {
    var rawIndex = -rawPos / step;
    var idx = Math.round(rawIndex);
    if (Math.abs(velocity) > SNAP_VELOCITY_BIAS) {
      idx += velocity < 0 ? 1 : -1;
    }
    return clamp(idx, 0, TOTAL - 1);
  }

  function pushSample(t, x) {
    samples.push({ t: t, x: x });
    while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
  }
  function releaseVelocity() {
    if (samples.length < 2) return 0;
    var a = samples[0], b = samples[samples.length - 1];
    var dt = b.t - a.t;
    if (dt <= 0) return 0;
    return (b.x - a.x) / dt;
  }

08カードが上下に重なって進む縦型カルーセル

カードは左右ではなく上下に重なりながら進み、次のカードが下から上がってきて手前のカードにわずかに重なりながら入れ替わります。onCardClick()は一番手前のカードがクリックされたときだけ進み、onWheel()は短いクールダウンを挟んでホイールの方向を読み取るため、一度のスワイプで5回分の遷移が同時に走ることはありません。横方向にスクロールする列よりも、縦長でモバイル向けのストーリーやお知らせカードのスタックに向いています。

カードが上下に重なって進む縦型カルーセル
新しいタブで開く

08 · カードが上下に重なって進む縦型カルーセル — 計測 累積面積 40.394% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

onWheelonCardClickzIndex
  function onCardClick(e) {
    var el = e.target.closest('.card');
    if (!el || cards.indexOf(el) !== order[0]) return; // 一番手前のカードを押したときだけ進める
    human();
    rotate(1);
  }
  function onWheel(e) {
    var now = performance.now();
    if (now - lastWheel < WHEEL_COOLDOWN_MS) { e.preventDefault(); return; }
    lastWheel = now;
    human();
    rotate(e.deltaY > 0 ? 1 : -1);
    e.preventDefault();
  }

09カードがぼやけながら奥へ下がるクロスフェード カルーセル

次のカードがくっきり浮かび上がる間、前のカードは薄くなりながらわずかに縮み、同じ位置のまま奥へ沈んでいくように見えます。easeInOutCubic()がその動きを直線的ではなく緩やかに始まり緩やかに終わるよう整形し、paintStatic()は初回表示を含めてアニメーションを再生しない場面でも同じ不透明度・拡大率・奥行きの組み合わせを瞬時に描きます。アニメーション中でも一度だけ描画したときでも遷移が同じに見えるのは、この計算を共有しているためです。

カードがぼやけながら奥へ下がるクロスフェード カルーセル
新しいタブで開く

09 · カードがぼやけながら奥へ下がるクロスフェード カルーセル — 計測 累積面積 34.82% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

easeInOutCubictranslateZpaintStatic

動きを最小限にしたい落ち着いた背景スライドに向いています。

  // opacity・scale・translateZ の3つの値を同時に計算する。
  function easeInOutCubic(p) {
    return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
  }

  function paintStatic() {
    cards.forEach(function (el, n) {
      var active = n === current;
      el.style.opacity = active ? '1' : '0';
      el.style.transform = active ? 'translateZ(0) scale(1)' : 'translateZ(' + (-DEPTH_PUSH) + 'px) scale(' + (1 - DEPTH_SCALE).toFixed(3) + ')';
      el.style.zIndex = active ? '2' : '1';
    });
    dots.forEach(function (el, n) { el.classList.toggle('isActive', n === current); });
    readout.textContent = LABEL_PREFIX + (current + 1) + LABEL_SEP + TOTAL;
  }

値がUIを変える場所 — 慣性減衰・跳ね返り強度・自動再生間隔

01 — MOMENTUM_FRICTION

MOMENTUM_FRICTIONを0.10から0.05まで下げると、settle()が毎フレームかける減衰が緩やかになり、VELOCITY_STOPに達するまでの時間が延びるため、手を離した後カードがより長く、より遠くまで滑ります。

05 — AUTOPLAY_MS

AUTOPLAY_MSを1350から900まで狭めると、tick()がその間隔に達するまでの時間が短くなり、カードがより頻繁に切り替わって全体の流れが速く感じられます。

07 — SNAP_EASE

SNAP_EASEを0.24から0.12まで下げると、毎フレーム残り距離を詰める割合が小さくなるため、手を離した後カードが定位置に収まる動きがより余裕をもって長く見えます。

どこで壊れるか — アクセシビリティ・reduced-motion・タッチスワイプの競合

9つのデモはすべて起動時に一度だけprefers-reduced-motionを確認しますが、reduced motionが止めるのは、誰も触れていないカルーセルを勝手に回し続けるページ自身のdemoCycle()という自動再生ループだけです。実際に来訪者がドラッグ・クリック・矢印キー操作をした瞬間には、デモと同じ速度・スプリング・スナップの計算がそのまま全力で走ります — それが来訪者が実際に使う操作経路だからです。ドラッグはブラウザ自身のタッチスクロールとポインターを奪い合う関係にもあるため、各デモは自分が担当する軸でだけsetPointerCapture()とpreventDefault()を呼び、ページ全体の動きとは競合しないようにしています — この呼び出しを省くと、スマートフォンでの横方向のドラッグがページの縦スクロールと競合してしまいます。reduced-motionの判定はあくまで起動時の一回きりで、ドラッグやクリックが起きるたびに毎回判定し直しているわけではないという点も、実装するときに見落としやすいところです。

  • 01 ドラッグ慣性 — reduced motionはドラッグそのものには影響せず、止まるのは自動デモ再生だけです。
  • 02 ゴムのような跳ね返り — 実際のドラッグで端を越えるたびに、スプリングの計算がそのまま走ります。
  • 03 3D遠近のカバーフロー — 角度と奥行きの再計算はrender()にあり、デモループとは無関係に動き続けます。
  • 04 無限ループ — wrapIfNeeded()は設定に関係なく、実際の遷移が終わるたびに毎回発火します。
  • 05 自動再生とホバー停止 — 唯一の例外です。playing = !reduceが最初から自動再生エンジン自体を止めます。継続的な無人の動きそのものが、ここでの本当の前庭系への配慮点だからです。
  • 06 サムネイル同期 — centerActiveThumb()は実際のクリックや矢印キー操作のたびにイージングで動きます。
  • 07 スナップポイント — 実際にドラッグを離すたびに、最寄りのカードへ収束させる計算が走ります。
  • 08 縦型スタック — クリックでもホイールでも、カードの積み重なり方は変わりません。
  • 09 クロスフェード — ドットのナビゲーションにはrole="tablist"が付いており、reduced-motionでも安全なpaintStatic()は初回表示時に呼ばれるのと同じ関数です。

9つ全部のHTML・CSS・JSのソース一式はqswujuq5の先のダウンロードに入っているため、フェンスから書き写さなくても、どのパターンもそのままプロジェクトに組み込めます。

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

バリエーション 変更した値 感じ方
慣性減衰係数を下げる 01 MOMENTUM_FRICTION 0.10 → 0.05 MOMENTUM_FRICTIONを0.10から0.05まで下げると、手を離した後カードがより長く、より遠くまで滑ります。
自動再生の切替間隔を狭める 05 AUTOPLAY_MS 1350 → 900 AUTOPLAY_MSを1350から900まで狭めると、カードがより頻繁に切り替わり、全体の流れが速く感じられます。
スナップ整列の移動時間を延ばす 07 SNAP_EASE 0.24 → 0.12 SNAP_EASEを0.24から0.12まで下げると、手を離した後カードが定位置に収まる動きがより余裕をもって長く見えます。

どのバリエーションも定数ひとつの変更なので、書き換えではなく一行の編集で感触を試せます。

画像 スライダー の実装例はどこで見られる?

この9つの先にある実装例を見ていくと、共通する習慣が見えてきます。動きはクラス切り替えに一度のCSStransitionを発火させるのではなく、ポインターの速度・経過時間・ドラッグ距離といった実際の入力から計算されています。見た目だけ動いているように見えるデモと、来訪者のドラッグ・待機・クリックに実際に応答するデモを分けているのは、その一点です。

FAQ

自動再生のカルーセルと普通の画像スライダーは何が違いますか?

普通のスライダーは、多くの場合クラスの切り替えと1回のCSS transitionだけで画像を差し替えます。この記事の9パターンは、その下に運動量・スプリング・経過時間といった実際に働く一層をもう一段加えているため、動きがきっかけに応じて変わり、常に同じには見えません。

手を離した後、カードによって滑る距離が違うのはなぜですか?

離した瞬間の速度が異なるためです。releaseVelocity()は指を離す直前の数サンプルでポインターがどれだけ動いたかを測っており、速く離すほどsettle()に渡る初速が大きくなり、VELOCITY_STOPを下回るまでに必要なフレーム数も増えます。

カルーセル アニメーション css の実装にSwiperやSlickのようなライブラリは必要ですか?

いいえ。ここで紹介したどのパターンも、素のrequestAnimationFrame・Pointer Events・CSS transformだけで動いており、外部スクリプトは使っていません。9パターンをまとめても、スライダー用ライブラリ1つ分よりずっと軽いファイルサイズに収まっているのはそのためです。

Sources: Window: requestAnimationFrame() method ・ Pointer events ・ CSS transform を使う ・ prefers-reduced-motion ・ perspective

Related reading: 【一行でコピペOK】CSS 3D アニメーション9選 ・ 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応 ・ ビフォーアフター スライダー UI 9選 — ドラッグで比較

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

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