Swipe Cards HTML CSS JS — Tinder-Style Card Stack

Swipe Cards · Cards · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Pointer Events API: pointerdown/move/up for mouse+touch, setPointerCapture() to prevent drag sticking on fast moves
Rotation math: rotation = (dx / cardWidth) * MAX_ROTATION — horizontal drag to natural card rotation mapping
translate+rotate transform: card.style.transform = translate(${dx}px, ${dy}px) rotate(${rotation}deg) per frame
Distance threshold: |dx| > cardWidth*0.35 triggers badge visibility — opacity proportional to overshoot amount
Velocity snap: vx computed from last pointermove delta — fast flick dismisses without reaching distance threshold
Exit animation: translateX to window.innerWidth+cardWidth off-screen, transitionend removes card from DOM
Spring snap-back: cubic-bezier(0.175, 0.885, 0.32, 1.275) elastic easing — card bounces back to center
Stack depth: scale(1-i*0.05) + translateY(i*8px) per depth level — perspective illusion without 3D transforms
Stack advance: data-index update + CSS transition on remaining cards when front card is dismissed

About this UI Snippet

Swipe Cards — How to Build a Tinder-Style Swipe Card Stack with Pointer Events and CSS Transform in JavaScript

Screenshot of the Swipe Cards snippet rendered live

Swipe cards — the gesture-driven interaction made famous by Tinder — have become a standard UX pattern for binary decision interfaces: left to reject, right to accept. The pattern works because it maps physical gesture to decision direction intuitively, and the card's visual rotation feedback confirms the direction before the user releases.

Building swipe cards correctly requires implementing: pointer event tracking across mouse and touch, rotation math that makes the card feel physically connected to the gesture, velocity detection for snap-to-reject/accept, and a card stack depth illusion using CSS transform and z-index.

This snippet builds a complete swipe card stack entirely in HTML, CSS, and vanilla JavaScript — no Hammer.js, no gesture library, no framework.

Pointer Events for Cross-Device Dragging

The snippet uses the Pointer Events API (pointerdown, pointermove, pointerup, pointercancel) rather than separate mousedown/touchstart handlers. Pointer Events unify mouse, touch, and stylus input in a single event model with identical APIs — e.clientX, e.clientY, e.pointerId. setPointerCapture(e.pointerId) on the card element on pointerdown ensures pointermove events continue firing on the card even if the pointer moves outside the element, preventing the drag from "sticking" when the mouse moves fast.

Drag State and Rotation Math

On pointerdown: capture the start position (startX = e.clientX, startY = e.clientY), the card center position, and mark isDragging = true.

On each pointermove: compute the offset from start: dx = e.clientX - startX, dy = e.clientY - startY. The rotation is proportional to horizontal drag but amplified by vertical position relative to card center. A common formula: rotation = (dx / cardWidth) * MAX_ROTATION where MAX_ROTATION is 20–25 degrees. This creates the natural "flicking" feel where dragging from the bottom rotates more than dragging from the top.

Apply via CSS transform: card.style.transform = \translate(${dx}px, ${dy}px) rotate(${rotation}deg)\``. The translation moves the card with the pointer. The rotation makes it feel like a physical card being dragged.

Accept/Reject Thresholds and Visual Feedback

While dragging, the snippet computes whether the card has crossed the acceptance threshold: Math.abs(dx) > cardWidth * 0.35 (35% of card width). If exceeded, the card shows visual feedback — a semi-transparent accept badge (green ✓) or reject badge (red ×) that increases in opacity proportional to how far beyond the threshold the drag has gone: badgeOpacity = Math.min(1, (Math.abs(dx) - threshold) / (threshold * 0.5)).

This matches Tinder's exact interaction pattern: the label appears gradually as you drag further, giving users confirmation of their decision direction before release.

Snap and Dismiss on Release

On pointerup: if |dx| > threshold or if the pointer velocity |vx| exceeds a snap velocity threshold, the card is dismissed in the direction of the drag. Otherwise it snaps back to center with a CSS transition.

Velocity is computed from the last few pointermove events: store the last timestamp and position, compute vx = (currentX - prevX) / (currentTime - prevTime). A high velocity snap means a fast flick dismisses the card even if it hasn't crossed the distance threshold — matching the feel of real card flicking.

Dismiss: set card.style.transition = 'transform 0.3s ease-out', then card.style.transform = \translate(${exitX}px, ${exitY}px) rotate(${exitRotation}deg)\`. The exitX is window.innerWidth + cardWidth (off right edge) or -window.innerWidth - cardWidth (off left edge). After the transition ends (transitionend` event), the card element is removed from the DOM.

Snap back: set card.style.transition = 'transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)' (a spring bounce cubic-bezier) and card.style.transform = 'translate(0, 0) rotate(0deg)'. The spring easing makes the snap-back feel elastic and satisfying.

Card Stack Depth Illusion

The stack of cards behind the active front card is achieved with CSS transforms and z-index. Each card at depth i from the top gets: transform: scale(${1 - i * 0.05}) translateY(${i * 8}px) and z-index: ${CARDS.length - i}. The front card (i=0) is full-size at z-index N. The card behind (i=1) is 5% smaller and 8px lower. The third card (i=2) is 10% smaller and 16px lower.

When the front card is dismissed, the remaining cards animate forward: each card transitions from its depth-i transform to its depth-(i-1) transform. This is implemented by updating the data-index attribute on each remaining card and recomputing the CSS transform, triggering the CSS transition.

Done State and Restart

When all cards are swiped, the stack container hides and a "done" panel appears with a Restart button. The Restart button re-clones the CARDS array, re-renders the full stack, and hides the done panel. This creates an infinitely replayable stack without page reload.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to reverse-engineer the drag math by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how applyDragTransform derives the rotation from horizontal drag distance capped at MAX_ROTATION, and why flyOut removes the card from the deck array before its transitionend fires rather than after. The same assistant can help optimize it — for instance whether renderStack rebuilding the entire visible stack with innerHTML on every card removal is wasteful compared to just animating the remaining cards to their new depth, or whether attaching mousemove and mouseup listeners to document on every drag start (and never removing the touch ones) could leak. It's also useful for extending the deck: ask it to add velocity-based flicking so a fast short swipe dismisses a card even under the distance threshold, track accepted versus rejected cards into separate arrays, or load real profile photos instead of gradients. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a Tinder-style "swipe card stack" in plain HTML, CSS, and JavaScript using mouse and touch drag events and CSS transforms — no gesture library, no framework.

Requirements:
- A deck array of card data objects, rendered as a stack of absolutely positioned cards where only a fixed number (e.g. four) are visible at once, each successive card behind the top one scaled slightly smaller and offset downward via a translateY plus scale transform, with z-index decreasing with depth.
- The frontmost card must be draggable by both mouse and touch, using a single set of coordinate-reading logic that works for both input types (reading touches[0].clientX/clientY for touch events, clientX/clientY for mouse events).
- While dragging, the card's transform must combine a translate matching the pointer's horizontal and vertical offset with a rotation proportional to the horizontal offset, clamped to a maximum rotation angle in either direction.
- Two overlay "LIKE" and "NOPE" stamp elements on the card must fade in during the drag, with opacity proportional to how far the horizontal drag has progressed toward a throw-distance threshold, showing only the stamp matching the current drag direction.
- On release, if the horizontal drag distance meets or exceeds the threshold, animate the card flying off-screen in that direction with a rotation and a fade to opacity zero, remove it from the deck data, and re-render the stack once the fly-out transition completes; if the drag falls short of the threshold, animate the card snapping back to its resting position and fade the stamps back out.
- Provide two buttons (reject and accept) and Left/Right arrow key bindings that trigger the same stamp-then-fly-out sequence programmatically without requiring an actual drag.
- When the deck is empty, show a completion state with a restart control that resets the deck to its original full list and re-renders the stack from scratch.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Drag a cardClick and drag the front card left or right. The card moves with your pointer and rotates naturally. A green ✓ or red × badge appears as you drag further.
  2. 2
    See the threshold indicatorDrag past 35% of the card width. The accept or reject badge reaches full opacity, indicating the card will be dismissed if released.
  3. 3
    Release to dismiss or snap backRelease past the threshold (or flick quickly) to dismiss the card off-screen. Release before the threshold to snap the card back to center with a spring animation.
  4. 4
    Use the action buttonsClick the × (reject) or ♥ (accept) buttons below the stack to trigger programmatic swipe animations without dragging.
  5. 5
    See the done stateAfter swiping all cards, a "You've seen everyone" message appears with a Restart button. Click Restart to reset the full stack.
  6. 6
    Customize the cardsEdit the CARDS array at the top of the JS. Each card has name, role, location, and gradient (CSS gradient string). The render function creates card elements from this data.

Real-world uses

Common Use Cases

Dating & Matching App UI
Build the core interaction of a dating or professional networking app. The swipe-left/right binary decision is the defining gesture of this category. Each card shows a profile gradient, name, role, and location — extend with real profile photos, match percentages, and shared interests data from your API.
Product Recommendation & Discovery Feed
Let users quickly accept or reject product recommendations, job listings, rental properties, or any items where binary triage is faster than browsing a grid. Right-swipe saves to a favorites list, left-swipe removes from the queue. The gesture speed enables users to process many items quickly.
Flashcard & Spaced Repetition Learning
Build a language learning, medical, or certification study app using swipe cards as flashcards. Right = "I know this", left = "review again later". The physical gesture reinforces the memory decision more than clicking a button. Pair with a spaced repetition algorithm that re-queues left-swiped cards.
Survey & Preference Sorting UI
Collect user preferences by having them swipe through options: product features they want vs don't want, design directions to pursue vs drop, or prioritized feature requests. The swipe format is more engaging than a checkbox list and produces binary sorted data directly.
Pointer Events & CSS Transform Study Reference
Study the complete implementation of pointer event drag tracking, setPointerCapture(), rotation math, velocity computation, threshold detection, spring easing, and CSS transform stack animation. These techniques apply to any drag-and-drop, sortable list, or gesture-controlled interface.
Content Curation & News Triage Tool
Build a news reader or content curation tool where users rapidly triage articles — save to read later (right) or discard (left). The gesture format is 3–5× faster than reading headlines and clicking save/skip buttons. Each card shows a headline, source, and category tag.

Got questions?

Frequently Asked Questions

The Pointer Events API unifies mouse (pointerdown/move/up), touch (single-touch maps directly), and stylus input in one event model with identical properties (clientX, clientY, pointerId). Without Pointer Events, you need separate mousedown/mousemove/mouseup and touchstart/touchmove/touchend handlers with duplicate logic. setPointerCapture(e.pointerId) is a key advantage: it routes all subsequent pointer events to the capturing element even when the pointer moves outside it — essential for fast drags that move off the card.

The rotation formula rotation = (dx / cardWidth) * MAX_ROTATION scales the rotation by how far across the card the pointer has moved. At dx=0 (no drag), rotation=0 (flat). At dx=cardWidth (dragged one full card width), rotation=MAX_ROTATION (e.g., 20°). This linear scaling makes the card feel like its top edge is fixed and you're pushing the bottom — the same physics as a real card lying on a table. The combined transform: translate(dx, dy) rotate(rotation) applies movement and rotation simultaneously.

On each pointermove, store the current timestamp and position. Compute vx = (currentX - prevX) / (currentTime - prevTime) in pixels per millisecond. On pointerup, if |vx| > SNAP_VELOCITY (e.g., 0.5 px/ms), dismiss the card in the direction of vx even if dx hasn't crossed the distance threshold. This implements the "flick" gesture: a fast short swipe should dismiss the card, while a slow long swipe that stops mid-way should snap back. Users expect the velocity response from real-world card flicking.

Each card has a data-depth attribute (0=front, 1=second, 2=third). CSS transitions are set on all cards: transition: transform 0.3s ease. When the front card is dismissed, the remaining cards have their data-depth decremented: depth 1 becomes 0, depth 2 becomes 1. A function reads the new depth and applies the updated transform: scale(1 - depth * 0.05) translateY(depth * 8px). The CSS transition animates each card from its old depth transform to its new one simultaneously, creating the "stack advancing forward" feel.

Add accepted and rejected arrays. In the dismiss handler: if (dx > 0) { accepted.push(CARDS[currentIndex]); } else { rejected.push(CARDS[currentIndex]); }. In the done state handler, display the counts: acceptedCount.textContent = accepted.length. To send the results to an API: fetch("/api/swipe-results", { method: "POST", body: JSON.stringify({ accepted: accepted.map(c => c.id), rejected: rejected.map(c => c.id) }) }) in the done handler or when the last card is dismissed.

Yes. The JSX, Vue, Angular, and Tailwind export buttons on this page convert the snippet automatically. In React, attach the pointerdown/pointermove handlers in a useEffect with cleanup, and track the card stack as state so removed cards trigger a re-render rather than manual DOM removal.