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

カード スワイプ ui 9パターン — 物理計算で仕上げる

このカード スワイプ ui には9パターンあります。指を離す速度が02番の基準に届かない瞬間、カードは飛ばずに03番のようにばねで元の位置へ戻る。最後の一枚をめくった先には08番の空のデッキだけが残る。

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

カード スワイプ ui 9パターン — ドラッグ値ひとつが判定をどう変えるか

以下の9つはすべて、ポインタードラッグから取れる二つの数値だけを読み取ります — どれだけ動いたか、そして離した瞬間どれだけ速かったかです。見た目の変化はすべて、その二つの値を別々の計算式に渡した結果です。01番は移動距離を回転角に変え、02番は速度をフリングか復帰かの判定に変えます。07番ではポインターすら使わず、押しっぱなしのキーが同じ判定をゼロから充電します。09番は回転・スプリング・遠近という三つの計算を一つのデッキに重ねます。順番に読むと、同じドラッグ値がだんだん多くの仕事を任されていく様子がわかります。

01ドラッグ距離に補間するカードの回転

カードを横に引くと、移動距離に比例して回転角が滑らかに補間されながら傾きます — 固定角へスナップすることはありません。ROTATE_LERP_RATIO は、ドラッグ1ピクセルあたりどれだけ傾きを足すかを決める定数で、値を上げると短いドラッグでも急な傾きに見え、下げるとデッキから外れる直前までほぼ平らなままになります。

ドラッグ距離に補間するカードの回転
新しいタブで開く

01 · ドラッグ距離に補間するカードの回転 — 計測 累積面積 49.342% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SWIPE_VELOCITY_THRESHOLD_PX_MSROTATE_LERP_RATIOEXIT_PCT
  function render() {
    var rot = clamp(pctX * ROTATE_LERP_RATIO, -ROTATE_MAX_DEG, ROTATE_MAX_DEG);
    card.style.transform = "translateX(" + pctX.toFixed(2) + "%) rotate(" + rot.toFixed(2) + "deg)";
    root.style.setProperty("--tone", clamp(Math.abs(pctX) / 42, 0, 1).toFixed(2));
  }

  function nextCard() {
    idx += 1; setCard();
    pctX = 0; phase = "idle";
    card.style.transform = "translateX(0%) rotate(0deg)";
    root.style.setProperty("--tone", "0");
    status.textContent = "待機中";
  }

  function judge(velocity, dir) {
    root.classList.remove("is-demo");
    var flung = Math.abs(velocity) >= SWIPE_VELOCITY_THRESHOLD_PX_MS;

計測ではこの回転カードが画面の49.3%を占め、平均強度は120.0、動きの検出は6/23フレームです。

02離す速度で分かれるスワイプ判定

離す瞬間の速度が基準値に届かないと、カードは飛んでいきません — それまでどれだけ引っ張っていたかは関係ありません。FLING_VELOCITY_MIN は、judge() が離した瞬間の速度と比較してフリングか復帰かを決める唯一の数値です。

離す速度で分かれるスワイプ判定
新しいタブで開く

02 · 離す速度で分かれるスワイプ判定 — 計測 累積面積 8.493% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FLING_VELOCITY_MINSWIPE_ROTATE_LERP_RATIOINERTIA_DECAY
  function judge(velocity, dir) {
    root.classList.remove("is-demo");
    hint.textContent = "速度 " + Math.abs(velocity).toFixed(2) + " px/ms";
    var flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
    if (reduce) {
      if (flung) { status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み"; nextCard(); }
      else { x = 0; render(); status.textContent = "基準速度未満 — 元に戻った"; phase = "idle"; }
      return;
    }
    if (flung) {
      phase = "exit"; exitDir = dir;
      status.textContent = dir > 0 ? "「いいね」処理済み" : "「いいえ」処理済み";
    } else {
      phase = "settle";
      status.textContent = "基準速度未満 — 元に戻った";
    }
  }

計測ではこの判定切り替えが画面の8.5%とやや控えめで、平均強度は76.5、動きの検出は6/23フレームです。

03ばねオーバーシュートで戻るカード

基準速度に届かないと、カードはただ元へ戻るのではなく、いったん中心を通り越してからばねで揺れ戻り、そこに落ち着きます。integrate() は毎フレーム SPRING_STIFFNESS と SPRING_DAMPING の組をカードの位置に対して計算しており、この二つの定数が戻り方を弾力のある動きにするか死んだ動きにするかを決めます。

ばねオーバーシュートで戻るカード
新しいタブで開く

03 · ばねオーバーシュートで戻るカード — 計測 累積面積 15.172% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SPRING_STIFFNESSSPRING_DAMPINGSWIPE_STACK_DEPTH_SCALE
  function integrate(dt) {
    var accel = SPRING_STIFFNESS * (0 - pos) - SPRING_DAMPING * vel;
    vel += accel * dt;
    pos += vel * dt;
    if (Math.abs(pos) < 0.4 && Math.abs(vel) < 4) { pos = 0; vel = 0; phase = "idle"; }
  }

計測ではこのオーバーシュートと収束が画面の15.2%を占め、平均強度は112.5、動きの検出は8/23フレームです。

04遠近投影で積み重なるカードスタック

後ろに積まれたカードは遠近投影で一枚ずつ小さくなり、上へずれて階段状に重なるので、デッキが平らな山ではなく奥行きとして見えます。STACK_PERSPECTIVE_SCALE は、各カードが共有する焦点距離にどれだけ食い込むかを決める値で、この縮み方の急さを左右します。

遠近投影で積み重なるカードスタック
新しいタブで開く

04 · 遠近投影で積み重なるカードスタック — 計測 累積面積 48.063% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STACK_PERSPECTIVE_SCALEFOCALRELEASE_VELOCITY_MIN
  function layoutStack() {
    for (var i = 1; i <= STACK_SIZE; i++) {
      var depth = i * STACK_PERSPECTIVE_SCALE * FOCAL;
      var scale = FOCAL / (FOCAL + depth);
      var ty = -(1 - scale) * 60;
      var el = peeks[i - 1];
      el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + scale.toFixed(3) + ")";
      el.style.opacity = (1 - i * 0.24).toFixed(2);
      el.style.zIndex = String(STACK_SIZE - i);
    }
  }

計測ではこのスタックの奥行き変化が画面の48.1%を占め、平均強度は113.9、動きの検出は6/23フレームです。

05ドラッグ距離でにじむいいね・ノーの判子

いいね・ノーの判子の不透明度は、ドラッグ距離に対して直線的には上がりません — 非線形に濃くなり、最初は薄いまま、離す距離に近づくにつれ急に追いつきます。STAMP_OPACITY_CURVE_POWER は stampOpacity() がその距離比を何乗するかを決める指数で、この曲がり方を左右します。

ドラッグ距離でにじむいいね・ノーの判子
新しいタブで開く

05 · ドラッグ距離でにじむいいね・ノーの判子 — 計測 累積面積 24.239% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAMP_OPACITY_CURVE_POWERSTAMP_RANGE_PXRELEASE_VELOCITY_MIN
  function stampOpacity(px) {
    var ratio = clamp(Math.abs(px) / STAMP_RANGE_PX, 0, 1);
    return Math.pow(ratio, STAMP_OPACITY_CURVE_POWER);
  }

  function render() {
    var rot = clamp(x * 0.07, -18, 18);
    card.style.transform = "translateX(" + x.toFixed(1) + "px) rotate(" + rot.toFixed(2) + "deg)";
    var op = stampOpacity(x);
    like.style.opacity = x > 0 ? op.toFixed(3) : "0";
    nope.style.opacity = x < 0 ? op.toFixed(3) : "0";
  }

計測ではこの判子の濃淡が画面の24.2%を占め、平均強度は53.0、動きの検出は6/23フレームです。

06逆補間で戻す最後のカード

元に戻すを押すと、直前にめくったカードは単にスナップして戻るのではなく、退出時に記録した経路を逆向きに、点ごとに補間しながらスタックの一番上へ戻ります。sampleReverse() がその保存された経路を逆にたどり、UNDO_DURATION_S が戻るまでの時間を決めます。

逆補間で戻す最後のカード
新しいタブで開く

06 · 逆補間で戻す最後のカード — 計測 累積面積 48.014% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

PATH_SAMPLE_MSUNDO_DURATION_SRELEASE_VELOCITY_MIN
  function sampleReverse(u) {
    var p = savedPath;
    var t0 = p[0].t, t1 = p[p.length - 1].t;
    var span = Math.max(t1 - t0, 1);
    var frac = clamp(u / UNDO_DURATION_S, 0, 1);
    var targetT = t1 - frac * span; // 時間を逆方向になぞる
    for (var i = 0; i < p.length - 1; i++) {
      if (targetT >= p[i].t && targetT <= p[i + 1].t) {
        var seg = Math.max(p[i + 1].t - p[i].t, 0.001);
        var localFrac = (targetT - p[i].t) / seg;
        return p[i].x + (p[i + 1].x - p[i].x) * localFrac;
      }
    }
    return p[0].x;
  }

計測ではこの逆補間による復帰が画面の48.0%を占め、平均強度は128.9、動きの検出は6/23フレームです。

07矢印キー・ボタンでも同じ判定を出す代替経路

ドラッグせず矢印キーやボタンを押しても、ドラッグと同じいいね・ノーの判定経路をそのまま通ります — キーボード専用の別結果はありません。キーを押し続けると CHARGE_RATE で値がたまり、離した瞬間に CHARGE_DECAY で減っていき、この充電量がドラッグの離す速度の代わりを果たします。

矢印キー・ボタンでも同じ判定を出す代替経路
新しいタブで開く

07 · 矢印キー・ボタンでも同じ判定を出す代替経路 — 計測 累積面積 18.896% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

CHARGE_RATECHARGE_DECAYCHARGE_RELEASE_MIN
  function step(dt) {
    var dtS = dt / 1000;
    if (phase === "charging") {
      var held = chargingDir > 0 ? keyDown.right : keyDown.left;
      charge = clamp(charge + (held ? CHARGE_RATE : -CHARGE_DECAY) * dtS, 0, CHARGE_MAX);
      x = chargingDir * charge * 90;
      render();
    } else if (phase === "exit") {
      x += exitDir * Math.max(Math.abs(vx) || 0.6, 0.6) * dt;
      render();
      if (Math.abs(x) > EXIT_PX) nextCard();
    } else if (phase === "settle") {
      x += (0 - x) * Math.min(dtS * 6, 1);
      charge += (0 - charge) * Math.min(dtS * 5, 1);
      render();
      if (Math.abs(x) < 0.4) { x = 0; charge = 0; phase = "idle"; render(); }
    }
  }

計測ではこの充電から解放までの動きが画面の18.9%を占め、平均強度は71.7、動きの検出は9/23フレームです。

08ばねで跳ねて現れる空スタックカード

カードをすべてめくると空状態の案内が出て、再補充を押すと新しいカードはただフェードインするのではなく、実寸を通り越してからばねで跳ねて落ち着きます。REFILL_SPRING_STIFFNESS と REFILL_SPRING_DAMPING は03番と同じ加速・収束のループを、位置ではなく拡大率に対して回しています。

ばねで跳ねて現れる空スタックカード
新しいタブで開く

08 · ばねで跳ねて現れる空スタックカード — 計測 累積面積 42.749% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

REFILL_COUNTREFILL_SPRING_STIFFNESSREFILL_SPRING_DAMPING
  function integrateScale(dt) {
    var accel = REFILL_SPRING_STIFFNESS * (1 - scale) - REFILL_SPRING_DAMPING * scaleVel;
    scaleVel += accel * dt;
    scale += scaleVel * dt;
    if (Math.abs(1 - scale) < 0.004 && Math.abs(scaleVel) < 0.02) { scale = 1; scaleVel = 0; phase = "idle"; }
  }

計測ではこのばね式の再補充が画面の42.7%を占め、平均強度は198.2、動きの検出は6/23フレームです。

09回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面

01番の回転補間・03番のスプリング復帰・04番の奥行きスタックを一つのデッキに重ねているので、カード一枚をめくると三つの値が別々にではなく同時に動きます。HERO_STACK_PERSPECTIVE_SCALE が後ろで待つカードを縮め、HERO_ROTATE_LERP_RATE が手前のカードを傾け、どちらも同じドラッグ値を読み取っています。

回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面
新しいタブで開く

09 · 回転・スプリング・奥行きスタックを一つのデッキに合わせたヒーロー場面 — 計測 累積面積 22.597% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HERO_ROTATE_LERP_RATEHERO_STACK_PERSPECTIVE_SCALESPRING_STIFFNESS
  function layoutStack() {
    for (var i = 1; i <= STACK_SIZE; i++) {
      var depth = i * HERO_STACK_PERSPECTIVE_SCALE * FOCAL;
      var s = FOCAL / (FOCAL + depth);
      var ty = -(1 - s) * 58;
      var el = peeks[i - 1];
      el.style.transform = "translateY(" + ty.toFixed(2) + "%) scale(" + s.toFixed(3) + ")";
      el.style.opacity = (1 - i * 0.24).toFixed(2);
      el.style.zIndex = String(STACK_SIZE - i);
    }
  }

計測ではこの合成ヒーロー場面が画面の22.6%を占め、平均強度は100.6、動きの検出は10/23フレームです。

9パターンを面積と強度で測定した結果

| 項目 | 面積 % | 強度 | 動いたフレーム |

| --- | --- | --- | --- |

| 01 ドラッグ距離に補間する回転 | 49.3% | 120.0 | 6/23 |

| 02 離す速度で分かれる判定 | 8.5% | 76.5 | 6/23 |

| 03 スプリングオーバーシュート | 15.2% | 112.5 | 8/23 |

| 04 遠近スタック | 48.1% | 113.9 | 6/23 |

| 05 判子のフェードイン | 24.2% | 53.0 | 6/23 |

| 06 逆補間の元に戻す | 48.0% | 128.9 | 6/23 |

| 07 キーボード・ボタン代替経路 | 18.9% | 71.7 | 9/23 |

| 08 空デッキのばね再補充 | 42.7% | 198.2 | 6/23 |

| 09 ヒーローデッキ場面 | 22.6% | 100.6 | 10/23 |

スワイプで一枚ずつ判断させたい場面で使う

このUIは、ユーザーが一つの項目だけを見て判断し、次へ進む前に行動することを求める場面に向いています。02番の離す速度による判定は、遅いドラッグをうっかり決定として数えたくないマッチング型の閲覧キューに向いています。05番のにじむ判子は、そのキューに追加の説明文なしで「なぜそう判定されたか」を読める形で示します。07番のキーボード代替経路は、タッチやマウスのドラッグを前提にできない場面 — 例えばキーボードで操作するレビューキュー — で効いてきます。08番の空デッキ再補充は有限のキューを死んだカードを残さずに締めくくり、06番の元に戻すは誤ってスワイプしたモデレーション型のキューに戻り道を用意します。「一度に一枚」の側面をカルーセルで見るならcss カルーセル スライダーの作り方9選 — コピペでOKがタイマーや矢印で送る一列のカードを扱っており、スマホ ジェスチャー操作 9選 — 指で本当に動くUIは、このデッキのポインター処理が土台にしている指の追従パターンをより広く扱っています。

値がスワイプ判定を変える場所 — 速度しきい値・回転補間比率・スタック遠近の深さ

以下の三つの定数は見た目以上の仕事をしています。02番の SWIPE_VELOCITY_THRESHOLD_PX_MS は、judge() が離す速度と比較してフリングと判定するか、遅いドラッグとして元に戻すかを分ける境界線で、これをわずかに下回ると03番のオーバーシュートするばねが代わりに動きます。01番の SWIPE_ROTATE_LERP_RATIO は横方向のドラッグ1ピクセルがどれだけ傾きを生むかを決め、同じドラッグ距離でもこの比率だけでほぼ傾かないようにも急に傾くようにも見せられます。04番の SWIPE_STACK_DEPTH_SCALE は手前以外の各カードがどれだけ縮んでずれるかを決め、平らなカードの山に見えるか本物の奥行きに見えるかの違いを生みます。料金表 デザイン 実装9選 — 切り替えも計算も動くでは、ドラッグの離す速度ではなくトグルスイッチの上で同じ種類のしきい値を調整しています。

どこで壊れるか — 動作を減らす設定・遅いドラッグ・連続する高速スワイプ

この9つのデッキはどれも var reduce = matchMedia("(prefers-reduced-motion: reduce)").matches をあらかじめ持っており、judge()(08番だけは refill())は reduce が真なら即座に最終状態へスナップして次のカードへ進み、毎フレームの step(dt) ループ自体も if (!reduce) step(dt) で丸ごと呼ばれません — 03番のばね収束も07番の充電の減衰も04番・09番の遠近再計算も、reduced motion 環境ではそもそも回りません。離す前に SWIPE_VELOCITY_THRESHOLD_PX_MS(02番)や FLING_VELOCITY_MIN を一度も超えない遅いドラッグは、どれだけ距離を動かしても必ず settle 側の分岐に戻ります — 距離だけではスワイプとして数えられません。実際に壊れやすいのは02番の startDrag() で、ほかの8つが再ドラッグ開始のたびに phase = "idle" へ戻すのに対し02番だけそれをせず、前のカードがまだ exit フェーズ(退出アニメーション中)のうちに同じカード要素を素早くつかみ直すと、step() の退出処理とドラッグの位置更新が同じ x を同時に書き換えてしまいます。これらのビルドを支える9組の index.html・style.scss、ga6p3g89は、自作の10番目のスワイプパターンに同じ reduce 分岐と phase リセットが要るかどうかを確かめる一番早い方法です。

変形3つとアクセシビリティ

| 変形 | 定数 | 範囲 | 変わること |

| --- | --- | --- | --- |

| 01 速度しきい値を調整する | SWIPE_VELOCITY_THRESHOLD_PX_MS | 0.3 → 0.8 | 値を下げると軽く押しただけでカードが飛び、値を上げると強く投げないと判定が出ず、誤操作が減ります。 |

| 02 回転補間比率を調整する | SWIPE_ROTATE_LERP_RATIO | 0.05 → 0.25 | 値を小さくするとカードはほとんど傾かずに動き、値を大きくすると移動距離に敏感に反応して大きく傾きます。 |

| 03 スタック遠近の深さを調整する | SWIPE_STACK_DEPTH_SCALE | 0.04 → 0.14 | 値を小さくすると後ろのカードはほぼ同じ大きさで平たく重なり、値を大きくすると奥ほど大きく縮んで奥行きがはっきりします。 |

ここでのアクセシビリティは飾りではありません。07番はまさに、キーボードユーザーが左右矢印キーとボタンの組でポインタードラッグの完全な代替を得て、ドラッグと同じ judge() の判定へたどり着けるようにするために存在します。08番で再補充されるカードや、04番・09番で再計算されるスタックは、どちらも状態テキストに紐づいた aria-live 領域を通じて変化を知らせるので、スクリーンリーダーはカードの動きを見なくても「いいねと判定されました」「待機中」を聞き取れます。9つのビルドはどれも reduce 分岐を自前で持ち、その中で何を即時スナップに切り替えているかは、上の「どこで壊れるか」の節を参照してください。

カード スワイプ ui とスワイプ ジェスチャー は何が違うのか?

スワイプ ジェスチャーは、指やカーソルが画面上を十分な速さで一方向に動いた、という生のポインター入力そのものを指します。このUIは、そのジェスチャーが積み重なった一枚集中型のカードに届いたときにページが行うことです — 判定を下し(02番)、届かなければ復帰のばねを再生し(03番)、下にある次のカードを見せます(04番)。ジェスチャーは入力で、判定してから遷移させるデッキの側のロジックが、その入力をいいね・ノー・まだ何も起きていない、のどれかに変えます。

この9つを試す前によくある質問に答えます

カード スワイプ ui のパターンにタッチスクリーンは必須ですか?

必須ではありません — 07番のキーボード代替経路は、矢印キーとボタンの押下をポインタードラッグとまったく同じ judge() 呼び出しに通すことで、マウスのみ・キーボードのみの訪問者にも同じ判定を届けています。

スワイプがフリングか遅いドラッグかを分けるのは何ですか?

離す瞬間の速度だけです。02番では judge() がそれを SWIPE_VELOCITY_THRESHOLD_PX_MS(姉妹ビルドでは FLING_VELOCITY_MIN)と比較しており、離すまでにどれだけ引っ張ったかはこの比較に一切入りません。この仕組みが読み取っているポインター入力の仕様はMDNのPointer Eventsにまとまっています。

デモのデッキが触る前から勝手にスワイプしているのはなぜですか?

seedRand() が決定論的な合成ポインター操作の列を作り、読み込み直後からパターンが見える自動再生を行っています。実際のポインター・矢印キー・ボタン操作が一度でも入ると、root.classList.remove("is-demo") がその自動再生を完全に止めます。

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

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