GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Card Swipe UI: 9 Physics-Based Deck Patterns

Nine card swipe ui builds: The instant a release speed misses item 02's threshold, the card springs back like item 03 instead of flying off.

Auto-plays · click a tile to jump to its section · all nine in one zip

Card Swipe UI: How One Drag Value Changes the Verdict

All nine decks below read the same two numbers off a single pointer drag — how far the card moved and how fast it was moving when you let go — and every visual change you see is that pair driving a different calculation. Item 01 spends the distance on a rotation angle. Item 02 spends the speed on a fling-or-return verdict. By item 07 there's no pointer at all, and a held key charges the same verdict from zero. Item 09 stacks three of these calculations — rotation, spring, and depth — onto one deck at once. Reading the nine in order shows the same drag value getting asked to do more work each time.

01Card Rotation Lerped to Drag Distance

Dragging the card sideways tilts it with a rotation angle smoothly lerped to how far it has moved — nothing snaps to a fixed angle. ROTATE_LERP_RATIO is the constant that decides how much tilt each pixel of drag distance buys: raise it and a short drag already reads as a steep lean, lower it and the card stays close to flat until it's nearly off the deck.

Card Rotation Lerped to Drag Distance
Open in a new tab

01 · Card Rotation Lerped to Drag Distance — Measured area 49.342% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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 = "Waiting";
  }

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

Measured on this build, the rotating card covers 49.3% of the frame at an average intensity of 120.0, moving across 6 of the 23 sampled frames.

02Release-Velocity Swipe Verdict

If the release speed misses the threshold, the card doesn't fly off — it doesn't matter how far it traveled before you let go. FLING_VELOCITY_MIN is the single number judge() checks against the pointer's speed at release to decide fling versus return.

Release-Velocity Swipe Verdict
Open in a new tab

02 · Release-Velocity Swipe Verdict — Measured area 8.493% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLING_VELOCITY_MINSWIPE_ROTATE_LERP_RATIOINERTIA_DECAY
  function judge(velocity, dir) {
    root.classList.remove("is-demo");
    hint.textContent = "Speed " + Math.abs(velocity).toFixed(2) + " px/ms";
    var flung = Math.abs(velocity) >= FLING_VELOCITY_MIN;
    if (reduce) {
      if (flung) { status.textContent = dir > 0 ? "Marked as liked" : "Marked as disliked"; nextCard(); }
      else { x = 0; render(); status.textContent = "Below the release-speed threshold — snapped back"; phase = "idle"; }
      return;
    }
    if (flung) {
      phase = "exit"; exitDir = dir;
      status.textContent = dir > 0 ? "Marked as liked" : "Marked as disliked";
    } else {
      phase = "settle";
      status.textContent = "Below the release-speed threshold — snapped back";
    }
  }

Measured on this build, the verdict swap covers a modest 8.5% of the frame at an average intensity of 76.5, moving across 6 of the 23 sampled frames.

03Card That Snaps Back With a Spring Overshoot

Falling short of the threshold, the card doesn't just glide home — it springs back, overshoots past center, and settles there. integrate() runs SPRING_STIFFNESS and SPRING_DAMPING against the card's position every frame, and that pair of constants is what decides whether the return reads as elastic or dead.

Card That Snaps Back With a Spring Overshoot
Open in a new tab

03 · Card That Snaps Back With a Spring Overshoot — Measured area 15.172% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

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"; }
  }

Measured on this build, the overshoot-and-settle covers 15.2% of the frame at an average intensity of 112.5, moving across 8 of the 23 sampled frames.

04Card Stack With Perspective Depth

Cards further back in the stack shrink and shift upward one step at a time under a perspective projection, so the deck reads as depth instead of a flat pile. STACK_PERSPECTIVE_SCALE sets how much each additional card divides into the shared focal length, which is what decides how steep that shrink looks.

Card Stack With Perspective Depth
Open in a new tab

04 · Card Stack With Perspective Depth — Measured area 48.063% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
    }
  }

Measured on this build, the stack's shifting depth covers 48.1% of the frame at an average intensity of 113.9, moving across 6 of the 23 sampled frames.

05Like/Nope Stamp That Fades In With Drag Distance

The like/nope stamp's opacity doesn't rise in a straight line with drag distance — it deepens non-linearly, staying faint at first and then catching up fast near release range. STAMP_OPACITY_CURVE_POWER is the exponent stampOpacity() raises that distance ratio to, and it's what bends the curve.

Like/Nope Stamp That Fades In With Drag Distance
Open in a new tab

05 · Like/Nope Stamp That Fades In With Drag Distance — Measured area 24.239% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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";
  }

Measured on this build, the stamp's fade-in covers 24.2% of the frame at an average intensity of 53.0, moving across 6 of the 23 sampled frames.

06Last Card Restored With Reverse Interpolation

Pressing undo doesn't just snap the last-swiped card back onto the stack — it interpolates it along the reverse of its own exit path, sampled point by point on the way out. sampleReverse() walks that saved path backward, and UNDO_DURATION_S decides how long the return takes.

Last Card Restored With Reverse Interpolation
Open in a new tab

06 · Last Card Restored With Reverse Interpolation — Measured area 48.014% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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; // sweeps time backwards
    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;
  }

Measured on this build, the reverse-interpolated return covers 48.0% of the frame at an average intensity of 128.9, moving across 7 of the 23 sampled frames.

07Keyboard/Button Fallback for the Same Verdict

Pressing an arrow key or button without dragging routes through the exact same like/nope verdict path a drag would — there's no separate keyboard-only outcome. Holding the key charges a value at CHARGE_RATE and lets it fall at CHARGE_DECAY the moment it's released, and that charge is what stands in for a drag's release velocity.

Keyboard/Button Fallback for the Same Verdict
Open in a new tab

07 · Keyboard/Button Fallback for the Same Verdict — Measured area 18.896% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

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(); }
    }
  }

Measured on this build, the charge-and-release swipe covers 18.9% of the frame at an average intensity of 71.7, moving across 9 of the 23 sampled frames.

08Empty-Deck Card That Springs Into View

Once every card is swiped away, an empty-state notice appears — and pressing refill doesn't just fade a fresh card in, it springs it into view past full scale before it settles. REFILL_SPRING_STIFFNESS and REFILL_SPRING_DAMPING run through the same accelerate-then-settle loop item 03 uses, applied to scale instead of position.

Empty-Deck Card That Springs Into View
Open in a new tab

08 · Empty-Deck Card That Springs Into View — Measured area 42.749% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

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"; }
  }

Measured on this build, the spring-in refill covers 42.7% of the frame at an average intensity of 198.2, moving across 6 of the 23 sampled frames.

09Hero Deck Scene Combining Rotation, Spring, and Depth Stack

Item 01's rotation lerp, item 03's spring snapback, and item 04's depth stack run on one deck here, so swiping a single card moves all three values together instead of one at a time. HERO_STACK_PERSPECTIVE_SCALE shrinks the cards waiting behind it while HERO_ROTATE_LERP_RATE tilts the one in front, both reading off the same drag.

Hero Deck Scene Combining Rotation, Spring, and Depth Stack
Open in a new tab

09 · Hero Deck Scene Combining Rotation, Spring, and Depth Stack — Measured area 22.597% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

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);
    }
  }

Measured on this build, the combined hero scene covers 22.6% of the frame at an average intensity of 100.6, moving across 10 of the 23 sampled frames.

Where a Card Swipe UI Actually Earns Its Place

A card swipe ui pattern fits wherever a user is meant to weigh one item at a time and act on it before moving to the next. Item 02's release-velocity verdict is the one to reach for on a dating-style browse queue, where an accidental slow drag shouldn't count as a decision. Item 05's fading like/nope stamp gives that same queue a readable "why did this count" signal without extra copy. Item 07's keyboard fallback matters anywhere the deck can't assume a touchscreen or a mouse drag is available — a review queue worked from a keyboard, for instance. Item 08's empty-deck refill closes out a finite queue instead of leaving a dead card behind, and item 06's undo gives a moderation-style queue a way back from a mis-swipe. Getting that swipe card ui ux decision to feel instant on release matters more than which easing curve draws the motion. For the carousel side of "one card at a time," 9 CSS Only Carousel Slider Recipes to Copy-Paste covers a row of cards advancing on a timer or arrow instead of a drag-and-discard deck, and 9 Mobile Touch Gestures That Follow Your Finger is the wider set of finger-tracking patterns this deck's pointer handling draws from.

Nine Card Swipe Patterns, Measured in Area and Intensity

| Item | Area % | Intensity | Moved frames |

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

| 01 Rotation Lerped to Drag Distance | 49.3% | 120.0 | 6/23 |

| 02 Release-Velocity Swipe Verdict | 8.5% | 76.5 | 6/23 |

| 03 Spring Overshoot Snapback | 15.2% | 112.5 | 8/23 |

| 04 Perspective Depth Stack | 48.1% | 113.9 | 6/23 |

| 05 Like/Nope Stamp Fade | 24.2% | 53.0 | 6/23 |

| 06 Reverse-Interpolated Undo | 48.0% | 128.9 | 6/23 |

| 07 Keyboard/Button Fallback | 18.9% | 71.7 | 9/23 |

| 08 Empty-Deck Spring Refill | 42.7% | 198.2 | 6/23 |

| 09 Hero Deck Scene | 22.6% | 100.6 | 10/23 |

Where the Value Changes the Swipe Verdict — Velocity Threshold, Rotation Lerp Ratio, Stack Perspective Depth

Three constants below decide more than they look like they do. SWIPE_VELOCITY_THRESHOLD_PX_MS in item 01 is the line judge() checks a release speed against before it will call a swipe a fling instead of a slow drag that snaps back — miss it by a hair and item 03's overshoot spring runs instead. SWIPE_ROTATE_LERP_RATIO in item 02 sets how much tilt each pixel of horizontal drag buys, so the same drag distance can look barely tilted or steeply cocked depending only on that ratio. SWIPE_STACK_DEPTH_SCALE in item 03 decides how much each card behind the front one shrinks and shifts, which is the difference between a stack that reads as flat cards and one that reads as real depth. Pricing Table Design: 9 Live Interactions tunes that same kind of threshold value on a toggle switch instead of a drag release.

Where It Breaks — Reduced Motion, Slow Drags, Rapid Consecutive Swipes

Every one of these nine decks already checks prefers-reduced-motion before it animates: each defines var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches, judge() branches on if (reduce) to report the verdict immediately instead of playing the exit or settle motion, and the main loop's if (!reduce) step(dt) guard skips the spring in item 03, the charge decay in item 07, and the perspective recalculation in items 04 and 09 outright once the query matches — none of the nine need that guard added; they ship with it. A slow drag that never crosses SWIPE_VELOCITY_THRESHOLD_PX_MS (item 01) or FLING_VELOCITY_MIN (item 02) before release always resolves to the settle branch, not the exit branch, no matter how far the card traveled — distance alone never counts as a swipe. The real risk in firing several swipes in rapid succession is a state collision, not a stray animation frame: startDrag() sets phase = "idle" the instant a pointer lands, so a second drag that starts before the previous card's exit finishes hijacks that same still-animating card element instead of waiting its turn. Item 06's undo closes that door on purpose — startUndo() disables its button the moment the reverse-interpolated return begins, so a second click mid-return has nothing to call and the card index only ever steps back once. The nine index.html/style.scss pairs behind these builds, ga6p3g89, are the fastest way to check whether a tenth swipe pattern of your own guards against the same collision.

Three Variants and Accessibility

| Variant | Constant | Range | What changes |

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

| 01 Adjust the velocity threshold | SWIPE_VELOCITY_THRESHOLD_PX_MS | 0.3 → 0.8 | Lowering the value sends the card flying with the slightest push, while raising it demands a real throw before the verdict fires, cutting accidental swipes. |

| 02 Adjust the rotation lerp ratio | SWIPE_ROTATE_LERP_RATIO | 0.05 → 0.25 | A smaller value keeps the card nearly flat as it slides, while a larger one makes it react sharply to travel distance for an exaggerated tilt. |

| 03 Adjust the stack perspective depth | SWIPE_STACK_DEPTH_SCALE | 0.04 → 0.14 | A smaller value keeps the cards behind almost the same size and flat, while a larger one shrinks them sharply with each layer for a pronounced sense of depth. |

Accessibility here isn't cosmetic. Item 07 exists specifically so a keyboard user gets the left/right arrow keys and a pair of buttons as a full substitute for a pointer drag, feeding the exact same judge() verdict a drag would. The refilled card in item 08 and the stack recalculated in items 04 and 09 both announce their change through an aria-live region tied to the status text, so a screen reader hears "marked as liked" or "waiting" without needing to see the card move. All nine builds already gate their animation loop behind prefers-reduced-motion — see the breaks section above for exactly what each one swaps in when that query matches.

What's the Difference Between Card Swipe UI and a Swipe Gesture?

A swipe gesture is the raw pointer input — a finger or cursor moving across the screen fast enough in one direction to register as a directional flick, nothing more. Card swipe ui design is what a page does once that gesture lands on a stacked, single-focus card: it decides a verdict (item 02), plays a return spring when the gesture falls short (item 03), and reveals the next card underneath (item 04). The gesture is the input; the deck's judge-and-transition logic is what turns that input into like, nope, or nothing happened yet.

Questions Worth Answering Before You Try One of These Nine

Does a card swipe ui pattern need a touchscreen to work?

No — item 07's keyboard fallback proves the point by routing arrow-key and button presses through the identical judge() call a pointer drag uses, so mouse-only and keyboard-only visitors get the same verdict path.

What decides whether a swipe counts as a fling instead of a slow drag?

Release velocity alone, checked in judge() against SWIPE_VELOCITY_THRESHOLD_PX_MS in item 01 (or FLING_VELOCITY_MIN in its sibling builds) — how far the card was dragged before release never enters that comparison. The same idea is documented at a browser level in MDN's Pointer Events reference, which is what every one of these nine decks listens to instead of separate mouse and touch handlers.

Why does the demo deck swipe on its own before I touch it?

seedRand() drives a deterministic auto-play sequence of synthetic pointer moves so the pattern is visible on load; the moment a real pointer, arrow key, or button interaction fires, root.classList.remove("is-demo") stops the scripted sequence for good.

Enter the archive password

The password is inside this article. You will find it as you read.