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

css ライトボックス 拡大アニメーション 9種まとめ

この css ライトボックス 9種のうち、1つはクリックしたサムネイルの位置からそのまま拡大し、1つは下向きスワイプの速度を計算して閉じるか戻すかを決め、1つはキーボード移動のたびにフォーカスリングが次の位置へ滑ります。

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

以下の9つのライトボックス転換パターンは、CDNからの読み込みなしに単体のHTML・CSS・JSファイルとして作られているため、アーカイブからどのフォルダを1つ取り出しても、そのindex.htmlをブラウザでそのまま開くだけで動作を確認できます。

デモ

上の各サムネイルを開いてそれぞれのライトボックスを試し、背景クリック・Escapeキー・そのパターンが用意しているジェスチャーで閉じてみてください。以下の数値とコードはすべて、後から要約したものではなく、その実際のファイルからそのまま読み取ったものです。

9つのパターンの解剖 — どこで計算の一層が回るか

下の表は、上のデモが生成したのと同じキャプチャから読み取った数値です。各パターンを同じ方法で連続サンプリングしています。累積面積は、キャプチャ全体で動いたピクセルがフレームの何%を占めたかの合計、平均強度は実際に何かが動いたフレームでのピクセル変化の強さ、動いたフレーム数(23フレーム中)は23回のフレーム間比較のうち何回に差が出たかを表します。

# パターン 累積面積 平均強度 動いたフレーム数(23フレーム中)
01 サムネイルの位置から広がるライトボックス 36.70% 111.0 15
02 背景フェード+画像スケールアップ切り替え 39.72% 79.2 14
03 キャプションが下から滑り上がるライトボックス 39.87% 110.8 21
04 スワイプで次・前へ移る ライトボックス 39.87% 95.2 20
05 拡大後にドラッグで移動するライトボックス 30.18% 53.5 9
06 下に投げるように閉じるライトボックス 39.87% 74.1 15
07 フォーカスリングが滑りながら閉じ込められるライトボックス 39.82% 106.4 20
08 サムネイル列が追従して動くライトボックス 39.87% 99.7 10
09 モーション削減を即座に反映するライトボックス 22.44% 78.2 17

03・04・06・08の4パターンは、いずれも背景の暗転と画面いっぱいの画像トランスフォームを同時に行うため、どの値が動きを生んでいるかに関わらずキャプチャに占める割合がほぼ同じになり、9パターン中最大の累積面積(約39.87%)で並んでいます。09は22.44%で最小、05は30.18%でそれに次いで小さく、この05は平均強度も53.5と全体最小、動いたフレーム数も23フレーム中9と最少です。ダブルタップやボタンでの拡大操作にしか反応せず、他の8パターンのような開閉サイクル自体を持たないためです。平均強度が最も高いのは01の111.0で、03の110.8にわずかに勝っています。動いたフレーム数が最も多いのは03で、23フレーム中21です。

クラスターA — 実際に測定した矩形がそのまま補間を駆動します、数式ではなく(01・03・07)

01・03・07の3パターンは、いずれも定数から開始値を計算するのではなく、画面上に実在する要素に対してgetBoundingClientRect()を呼ぶところから動きを始めます。01はクリックされたサムネイル自身の画面上の矩形を読み取り、フルサイズ画像がそこから広がる際のずれと拡大率を計算します。03はキャプションを画面外に一度配置した直後にその実際のレンダリング高さを読み取ります。短いキャプションと長いキャプションでは押し上げる距離が異なるためです。07は新しくフォーカスされたコントロールの矩形を読み取り、直前のリング位置からどこへ滑らせればよいかを正確に把握します。

クラスターB — 2つの閾値のORがどちらの分岐を実行するかを決めます(04・06・09)

04・06・09の3パターンは、いずれも同じ形の判定で1つの分岐を決めています。2つの独立した条件を||で結び、どちらか一方が真であれば十分という判定です。04のendDrag()は、ドラッグ距離がSWIPE_COMMIT_RATIOをビューポート幅に掛けた値を超えるか、離す瞬間の速度がSWIPE_VELOCITY_PX_MSを超えるか、どちらか早く成立した時点で次または前の画像への切り替えを確定します。06も同じ形の条件で、距離の割合かLIGHTBOX_FLING_VELOCITY_PX_MSを超える速度かによってflingClose()とspringBack()のどちらを実行するかを選びます。09のeffectiveReduce()はこの仕組みの最も単純な形で、realReduce || simulateReduceという判定により、OSの設定か実演用のスイッチのどちらかが真であれば、そのライトボックスを即座に切り替える状態にします。

クラスターC — 導出した比率が連続的なトランスフォームを可動域の中に収め続けます(02・05・08)

02・05・08の3パターンは、それぞれ異なる理由で値が変わるたびにclamp()を通します。02のfromAspect()は、画像の縦横比から正規化した1つの値をもとに開始スケールと背景の暗さを同時に導き出すため、縦長の写真と横長の写真では同じコードパスから異なる印象の開き方が生まれます。05のpanBounds()は、scale自体がまだ目標値へイージング中のアニメーションフレームごとに毎回再計算されるため、現在のズーム段階がまだ許していない位置へパンが動くことはありません。08のcenterOffsetFor()は帯の目標スクロール位置をclamp()で制限しており、アクティブなサムネイルが再配置される際に帯の最初や最後の項目を超えて動くことがないようにしています。

パターン別コード

以下は各パターンの実際のファイルから取り出した関数を1つずつ、ブラウザで動いているそのままの形で載せています。書き直しも省略もありません。ライトボックス デザインを検索すると、CSSの@keyframesだけで完結する開閉トランスフォームが数多く見つかりますが、以下の9つはいずれもトランジションの下でこうした計算された値を少なくとも1つ動かし続けています。

01サムネイルの位置から広がるライトボックス

グリッドのサムネイルをクリックすると、その画像があった位置とサイズからそのまま拡大が始まり、画面全体のライトボックスへ広がります。常に中央から現れる一般的なダイアログとは開始位置が異なります。

サムネイルの位置から広がるライトボックス
新しいタブで開く

01 · サムネイルの位置から広がるライトボックス — 計測 累積面積 36.697% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRecttransform-originFLIP technique
  function openFrom(idx) {
    if (open) return;
    open = true;
    var thumbImg = thumbs[idx].querySelector('.thumbImg');
    var fromRect = thumbImg.getBoundingClientRect();
    fullImg.className = 'fullImg ' + imgClassByIdx[idx];
    lightbox.style.visibility = 'visible';
    lightbox.setAttribute('aria-hidden', 'false');
    backdrop.style.visibility = 'visible';
    var toRect = fullImg.getBoundingClientRect();
    var dx = (fromRect.left + fromRect.width / 2) - (toRect.left + toRect.width / 2);
    var dy = (fromRect.top + fromRect.height / 2) - (toRect.top + toRect.height / 2);
    var scaleFrom = fromRect.width / toRect.width;
    animate(OPEN_MS, function (e) {
      var s = scaleFrom + (1 - scaleFrom) * e;
      var tx = dx * (1 - e);
      var ty = dy * (1 - e);
      fullImg.style.transform = 'translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px) scale(' + s.toFixed(4) + ')';
      backdrop.style.opacity = (e * 0.72).toFixed(3);
    }, function () {
      closeBtn.focus({ preventScroll: true });
    });
  }

02背景フェード+画像スケールアップ切り替え

背景が暗くフェードする間、画像自体は最終サイズよりわずかに小さい状態から始まり、少し拡大して止まります。一般的なダイアログのフェードスケールとは異なる実演で、開始スケールと背景の暗さが画像の縦横比から一緒に計算されます。

背景フェード+画像スケールアップ切り替え
新しいタブで開く

02 · 背景フェード+画像スケールアップ切り替え — 計測 累積面積 39.722% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clamp()transform: scalebackdrop opacity
  function clamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); }
  function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }

  // 画像自身の縦横比から開始スケールと背景の暗さを一緒に計算する — 2つの値が同じ正規化点から生まれる
  function fromAspect(w, h) {
    var aspect = w / h;
    var norm = clamp((aspect - ASPECT_NORM_LO) / (ASPECT_NORM_HI - ASPECT_NORM_LO), 0, 1);
    var scaleFrom = SCALE_FROM_MIN + (SCALE_FROM_MAX - SCALE_FROM_MIN) * norm;
    var dim = DIM_MAX - (DIM_MAX - DIM_MIN) * norm;
    return { aspect: aspect, scaleFrom: scaleFrom, dim: dim };
  }

  var open = false;
  var activeIdx = 1;
  var timers = [];

03キャプションが下から滑り上がるライトボックス

画像が広がり終えた少し後に、下部のキャプション帯が画面の下端から押し上げられるように現れます。画像とキャプションが同時ではなく、二段階で現れます。

キャプションが下から滑り上がるライトボックス
新しいタブで開く

03 · キャプションが下から滑り上がるライトボックス — 計測 累積面積 39.872% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

transform: translateYrequestAnimationFramestaggered timing
    animate(IMAGE_MS, function (e) {
      fullImg.style.transform = 'scale(' + (0.86 + 0.14 * e).toFixed(4) + ')';
      fullImg.style.opacity = e.toFixed(3);
      backdrop.style.opacity = (0.7 * e).toFixed(3);
    }, function () {
      // キャプション自身の実際のレンダリング高さを測定し、その高さの分だけ下から押し上げる — テキストの長さによって開始位置が変わる
      caption.style.visibility = 'hidden';
      caption.style.opacity = '1';
      var capH = caption.getBoundingClientRect().height;
      caption.style.transform = 'translateY(' + capH.toFixed(1) + 'px)';
      caption.style.visibility = 'visible';
      timers.push(setTimeout(function () {
        animate(CAPTION_MS, function (e2) {
          caption.style.transform = 'translateY(' + (capH * (1 - e2)).toFixed(1) + 'px)';
        }, function () {
          closeBtn.focus({ preventScroll: true });
        });
      }, CAPTION_GAP_MS));
    });

04スワイプで次・前へ移る ライトボックス

ライトボックスが開いた状態で左右にドラッグすると、現在の画像が押し出されて次または前の画像がその場所を埋めます。082のインラインカルーセルスライダーとは異なる実演で、こちらは全画面オーバーレイの中で番号カウンターとともに切り替わります。

スワイプで次・前へ移る ライトボックス
新しいタブで開く

04 · スワイプで次・前へ移る ライトボックス — 計測 累積面積 39.872% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Pointer EventssetPointerCapturevelocity threshold
  function endDrag(e) {
    if (!dragging) return;
    dragging = false;
    var deltaX = e.clientX - dragStartX;
    var w = viewportW();
    var commit = Math.abs(deltaX) > w * SWIPE_COMMIT_RATIO || Math.abs(velocity) > SWIPE_VELOCITY_PX_MS;
    if (commit) {
      goTo(idx + (deltaX < 0 ? 1 : -1));
    } else {
      goTo(idx);
    }
  }

05拡大後にドラッグで移動するライトボックス

ダブルタップやボタンで画像を拡大すると、その後はドラッグした方向へ画像が動きますが、拡大した分だけ生まれる余白の中でしか動けないよう移動範囲が毎回再計算されます。256のルーペ・ミニマップビューアーとは異なる実演で、こちらはライトボックスが開いている間の拡大・移動だけを扱います。

拡大後にドラッグで移動するライトボックス
新しいタブで開く

05 · 拡大後にドラッグで移動するライトボックス — 計測 累積面積 30.183% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

clamp()pointer drag pantransform: scale
  function panBounds(s) {
    var r = frame.getBoundingClientRect();
    return { x: (s - 1) * r.width / 2, y: (s - 1) * r.height / 2 };
  }

  function apply() {
    fullImg.style.transform = 'translate(' + offX.toFixed(2) + 'px,' + offY.toFixed(2) + 'px) scale(' + scale.toFixed(3) + ')';
    zoomNote.textContent = scale.toFixed(1) + '×';
  }

  function zoomTo(target, onDone) {
    var fromScale = scale;
    var fromX = offX, fromY = offY;
    animate(ZOOM_MS, function (e) {
      scale = fromScale + (target - fromScale) * e;
      var b = panBounds(scale);
      offX = clamp(fromX * (1 - e), -b.x, b.x);
      offY = clamp(fromY * (1 - e), -b.y, b.y);
      apply();
    }, onDone);
  }

06下に投げるように閉じるライトボックス

下方向にドラッグして指を離した瞬間、最後の移動速度が計算されます。十分速く振り払った時だけ画面外へ弾かれるように閉じ、遅い場合は元の位置に戻ります。

下に投げるように閉じるライトボックス
新しいタブで開く

06 · 下に投げるように閉じるライトボックス — 計測 累積面積 39.872% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointer velocityspringBack()translate()
  function flingClose(vy) {
    var h = stageH();
    var fromY = curY;
    var fromX = curX;
    var targetY = fromY + Math.sign(vy || 1) * (h * 1.4);
    var duration = clamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320);
    animate(duration, function (e) {
      var y = fromY + (targetY - fromY) * e;
      var progress = clamp(Math.abs(y) / h, 0, 1);
      setDrag(fromX * (1 - e), y, progress);
    }, closeLightbox, function (t) { return t; });
  }

  function springBack() {
    var fromX = curX;
    var fromY = curY;
    animate(SPRING_BACK_MS, function (e) {
      setDrag(fromX * (1 - e), fromY * (1 - e), 0);
    }, function () {
      curX = 0; curY = 0;
      closeBtn.focus({ preventScroll: true });
    }, easeSpringBack);
  }

07フォーカスリングが滑りながら閉じ込められるライトボックス

ライトボックス内でタブや矢印キーによってフォーカスが次のボタンへ移るたびに、フォーカス枠は瞬時に移動せず前の位置から次の位置へ滑るように動きます。今どこに焦点があるか目で追い続けられます。

フォーカスリングが滑りながら閉じ込められるライトボックス
新しいタブで開く

07 · フォーカスリングが滑りながら閉じ込められるライトボックス — 計測 累積面積 39.817% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getBoundingClientRectfocus trapwill-change: transform
    var fromRect = lastRingRect || { x: toX, y: toY, w: toW, h: toH };
    focusRing.style.width = toW + 'px';
    focusRing.style.height = toH + 'px';
    focusRing.style.transform = 'translate(' + toX + 'px,' + toY + 'px)';
    var dx = fromRect.x - toX;
    var dy = fromRect.y - toY;
    var sx = fromRect.w / toW;
    var sy = fromRect.h / toH;
    focusRing.style.opacity = '1';
    animate(LIGHTBOX_FOCUS_RING_MS, function (e) {
      var ix = dx * (1 - e);
      var iy = dy * (1 - e);
      var isx = sx + (1 - sx) * e;
      var isy = sy + (1 - sy) * e;
      focusRing.style.transform = 'translate(' + (toX + ix).toFixed(2) + 'px,' + (toY + iy).toFixed(2) + 'px) scale(' + isx.toFixed(4) + ',' + isy.toFixed(4) + ')';
    }, function () {
      lastRingRect = { x: toX, y: toY, w: toW, h: toH };
    });

08サムネイル列が追従して動くライトボックス

ライトボックス下部の小さなサムネイル帯では、今見ている写真のサムネイルだけに枠が付き、画像が切り替わるたびにその枠を中心に帯全体が再配置されます。082のスライダー自体のサムネイルトラックとは異なる実演で、こちらはスライダー本体ではなく既に開いているライトボックスに付随する補助の帯です。

サムネイル列が追従して動くライトボックス
新しいタブで開く

08 · サムネイル列が追従して動くライトボックス — 計測 累積面積 39.872% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

centerOffsetFor()transform: translateXscroll sync
  function goTo(nextIdx, onDone) {
    nextIdx = clamp(nextIdx, 0, COUNT - 1);
    idx = nextIdx;
    fullImg.className = 'fullImg fullImg--' + imgClassByIdx[idx];
    stripThumbs.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
    var target = centerOffsetFor(stripThumbs[idx]);
    var fromX = currentX;
    animate(SYNC_MS, function (e) {
      var x = fromX + (target - fromX) * e;
      setStripX(x);
    }, function () {
      currentX = target;
      if (onDone) onDone();
    });
  }

09モーション削減を即座に反映するライトボックス

モーションを減らす設定になっていると、拡大・スワイプ・弾む動きをすべて省略し、画像と背景がスイッチのように瞬時に現れて消えるようにします。

モーション削減を即座に反映するライトボックス
新しいタブで開く

09 · モーション削減を即座に反映するライトボックス — 計測 累積面積 22.44% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

matchMediaprefers-reduced-motionchange event
  // 実際のOS/ブラウザの設定 — この値がtrueなら、シミュレーションスイッチに関係なく常に即座に切り替わる
  var mql = window.matchMedia('(prefers-reduced-motion: reduce)');
  var realReduce = mql.matches;
  // 実演用のシミュレーションスイッチ — 実際の設定が通常でも、このパターンのライトボックスだけを強制的に即座に切り替えて差を比較する
  var simulateReduce = false;

  function effectiveReduce() { return realReduce || simulateReduce; }

値がUIを変える場所 — 拡大の起点・速度の閾値・保持時間

多くの css ライトボックスの解説はフェードの長さだけで終わりますが、上の各パターンは実際に働く変数を1つだけ持っており、その1つの値を変えるだけでパターン全体の印象が変わります。

01 — 開始スケール比

scaleFromはfromRect.width / toRect.width、つまりサムネイル自身の実測幅をフルサイズ画像の幅で割った値そのものです。固定された定数ではないため、小さいサムネイルと大きいサムネイルでは同じ関数から見た目の異なる開始点が生まれます。

02 — 正規化された縦横比の値

normはfromAspect()が画像の幅と高さから作り出す1つの数値で、scaleFromもdimもその同じnormを別々の定数のペアの間で補間しただけの値です。画像の縦横比を変えれば、この2つの値は必ず一緒に動きます。

03 — 測定されたキャプションの高さ

capHは、スライドが始まる直前にcaption.getBoundingClientRect().heightで測定されます。1行のキャプションは短い距離しか動かず、3行のキャプションはずっと遠くまで動きますが、どちらも同じCAPTION_MSの時間で完了します。

04 — 確定の閾値

endDrag()が見ているのはSWIPE_COMMIT_RATIOとSWIPE_VELOCITY_PX_MSの2つの定数だけです。十分な距離のドラッグか十分な速さのフリックのどちらかが成立すれば次の画像へ確定し、どちらかの定数を下げるほど短く弱いジェスチャーでもスワイプとして認められます。

05 — 再計算されるパン範囲

panBounds(scale)はzoomTo()の中でアニメーションフレームごとに毎回再計算されており、一度きりの計算ではありません。scale自体がまだ目標値へイージング中でも、offXとoffYはその時点の可動域できちんと制限されます。

06 — 速度に比例した継続時間

flingClose()のdurationはclamp(220 / Math.max(Math.abs(vy), 0.35), 120, 320)で決まります。速く離すほど短く俊敏なフリングになり、閾値ぎりぎりの速度で離すと上限に近い320msまで伸びます。

07 — 測定されたリングの矩形

lastRingRectはglideRingTo()が呼び出しの間で保持している唯一の状態です。これがなければリングには補間の起点となるfromRectがなく、フォーカスが移るたびに滑らずに瞬間移動するしかありません。

08 — クランプされた中心オフセット

centerOffsetFor()はgoTo()がアニメーションを始める前に必ずclamp()を通されており、これがアクティブなサムネイルを中心に寄せる際に帯の最初や最後の項目を超えて動かしてしまわないための唯一の一行です。

09 — 他のすべてのパターン自身のスイッチも動かすOR

effectiveReduce()(realReduce || simulateReduce)は、他の8つのファイルそれぞれのanimate()ヘルパーの中に独立して繰り返されている判定の、このパターンなりの版です。唯一の違いは、OS自体が通常のモーション設定のままでも比較のために即座の状態を強制できる、実演専用の2つ目のスイッチがある点です。

どこで壊れるか — アクセシビリティ・reduced-motion・フォーカス移動

07のorder配列は、閉じる・前へ・情報・次への4つのコントロールの間でキーボードフォーカスを巡回させ、glideRingTo()はそれぞれのfocusイベントのたびに実行されるため、Tabまたは Shift+Tabで次に移る先へリングが実際に滑って見え、最後のコントロールから最初へも正しく折り返します。しかし、このデモでclickハンドラーを持つのはcloseBtnだけです。prevBtn・infoBtn・nextBtnはトラップの中で他の実在するコントロールと同じようにフォーカスだけは受け取りますが、Enterキーやスペースキーを押しても何も起こりません。リングは今どこにフォーカスがあるかを正直に示していますが、4つの停止点のうち3つは、実際のギャラリーがその動作を実装するまで機能しないままです。

reduced motionで失われるもの

クリック・ドラッグ・キー操作といった各パターン自身のきっかけは、reduced motionの下でも同じように働きます。省略されるのは、その間のイージングされた動きだけです。各パターンが共有するanimate()ヘルパーが、通常のループを回す代わりに最終フレームを一度だけ呼び出して終わるためです。

  • 01 サムネイル変形 — 補間が止まり、fullImgは最終的な位置とスケールへ直接切り替わり、背景の不透明度も0.72へ一気に切り替わります。
  • 02 背景フェード+スケールアップ — イージングされる拡大が止まり、画像はすでに縦横比から導かれた最終スケールで、背景も最終的な暗さですぐに表示されます。
  • 03 キャプション上昇 — 二段階のアニメーションが両方止まり、画像は不透明度1で、キャプションも遅延やスライドなしにその場に表示されます。
  • 04 スワイプ切り替え — ドラッグ自体は変わらず動き、距離か速度で確定する判定も同じですが、確定した後の切り替え自体がイージングなしに一気に決まります。
  • 05 ズーム&パン — ダブルタップやボタンでの拡大はscaleを変え続けますが、値はイージングせず目標へ直接飛び、パンの可動域も同じ瞬間に更新されます。
  • 06 速度で閉じる — 速い解放も遅い解放も結果が即座に決まり、弧を描いて飛ぶ動きもバネのようなオーバーシュートも間に挟まりません。
  • 07 フォーカストラップリング — Tabと Shift+Tabはorderの両端で同じように折り返しますが、リングは次のコントロールへ滑らず瞬間移動します。
  • 08 サムネイル帯の追従 — 画像が切り替わるたびにcenterOffsetFor()の目標へ帯は同じように再配置されますが、それが1フレームで完了し、イージングは挟まりません。
  • 09 reduced-motion即時反映 — このパターンだけは何も変わりません。即座の切り替え自体がこのパターンの目的そのもので、effectiveReduce()はOSの設定とシミュレーションスイッチをどちらの状態でも変わらず反映し続けます。

これら9つのファイルはすべて同じ一つのパスワード、nhfnnp8k、を共有しています。一度zipを解凍すれば、上で見た9パターン分の動作するコピーがそのままディスク上に揃い、ブラウザで直接開いて確認できます。

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

ここでのバリエーションは、外部のcss 画像 拡大 アニメーションのライブラリを一切必要とせず、アーカイブ内の同じファイルに含まれる定数を書き換えるだけで試せます。

バリエーション 値 感触
サムネイル拡大の開始スケールを広げる 01 LIGHTBOX_MORPH_SCALE_FROM 0.3 → 0.6 開始サイズを場面幅の30%から60%に広げると、サムネイルはすでに大きい状態から始まるため移動距離が短くなり、拡大の印象が弱まります。
閉じる判定のスワイプ速度閾値を下げる 06 LIGHTBOX_FLING_VELOCITY_PX_MS 0.5 → 0.2 速度閾値を0.5から0.2に下げると、より遅い離し方でも投げたと判定され、閉じる条件がずっと緩くなります。
フォーカスリングの移動時間を延ばす 07 LIGHTBOX_FOCUS_RING_MS 220 → 400 移動時間を220msから400msに延ばすと、フォーカスリングが次の位置へよりゆっくり滑り、目で追いやすくなります。

9つのライトボックスはすべて同じダイアログの作法を共有しています。トリガーとなるサムネイルにはaria-haspopup="dialog"が付き、ライトボックス自体はrole="dialog"とaria-modal="true"を持ち、開閉のたびにaria-hiddenがtrueとfalseを切り替えます。スクリーンリーダーにとっては、9つのうちどのトランジションが開いていても同じ合図です。04だけは画像カウンター用にaria-live="polite"の領域を重ねており、イージングされたスライド自体を聞き取らなくても、スワイプでどの写真が今表示されているかがアナウンスされます。09のシミュレーションスイッチは、9つの中で唯一role="switch"とaria-checkedで作られたコントロールで、他のボタンのような一回きりの操作ではなく、本物のオン/オフの状態を表しているためです。

FAQ

これら9つのcssライトボックス転換は、実際の画像ホストやバックエンドが必要ですか。

いいえ。9つのファイルすべての画像はCSSのグラデーションかインラインSVGだけで作られており、ネットワークから何かを取得することも、Math.random()を使うこともありません。そのため、どのデモを再読み込みしても毎回まったく同じ数値と同じ動きになります。

prefers-reduced-motionが本当に尊重されているかは、どう確認すればよいですか。

OSのアクセシビリティ設定で「モーションを減らす」を有効にしてから上のデモを再読み込みしてみてください。9つすべてがwindow.matchMedia('(prefers-reduced-motion: reduce)')を呼び、同じanimate()のガードを通って最終状態へ直接切り替わります。09だけはさらに一歩進み、OSの設定を変えなくても画面上のスイッチだけで両方の状態を並べて比較できます。

css 画像 拡大 ポップアップのコードペン埋め込み版はありますか、それともダウンロードが必要ですか。

埋め込み版は1つのパターンだけを手早く見るには便利ですが、このページ自体には埋め込みを置いていません。アーカイブをダウンロードすれば、同じ9つのindex.htmlがすでに解凍された状態で手元に揃い、自分のディスク上でそのまま編集できます。

出典: MDN — Element.getBoundingClientRect()、MDN — Pointer events、MDN — dialog role、MDN — prefers-reduced-motion。

関連記事: 一行でコピペOK CSS アニメーション ギャラリー 9選 · 画像 スライダー css 9種 — GSAPなしでレスポンシブ対応 · 画像 拡大 表示 UI 9選 — 写真ファイルなしで作るビューア

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

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