Tinder-style stack of cards: drag the top card past a threshold (or flick it) and it flies off with rotation and fade, then cycles to the back of the deck; also operable with arrow keys and prev/next buttons.
Preview
Customize
3 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add swipe-cardsWrites components/ui/swipe-cards.tsx and components/styles/swipe-cards.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { SwipeCards } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <SwipeCards />6 );7}Props
Accessibility
The stack is a focusable role="group" that swipes the top card with ArrowLeft/ArrowRight, visible Swipe left/right buttons offer a pointer-free alternative, and a polite live region announces the current card. Cards beneath the top are aria-hidden, keys stay stable with duplicate items, and prefers-reduced-motion cycles cards without the fly-out animation.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";5import styles from "../styles/swipe-cards.module.css";6 7export type SwipeDirection = "left" | "right";8 9export interface SwipeCardsProps {10 /** Card labels, last item starts on top (duplicates are fine) */11 items?: string[];12 /** Horizontal drag distance in pixels that commits a swipe */13 threshold?: number;14 /** Show the previous/next buttons below the stack */15 showControls?: boolean;16 /** Called after a card is swiped away */17 onSwipe?: (item: string, direction: SwipeDirection, index: number) => void;18 /** Optional className */19 className?: string;20}21 22const DEFAULT_ITEMS = ["Kinetic Type", "Liquid Glass", "Magnetic Dock", "Holo Tilt", "Aurora"];23const VISIBLE = 4;24 25export function SwipeCards({26 items = DEFAULT_ITEMS,27 threshold = 100,28 showControls = true,29 onSwipe,30 className = "",31}: SwipeCardsProps) {32 const prefersReducedMotion = useReducedMotion();33 const list = items.length > 0 ? items : DEFAULT_ITEMS;34 const itemsKey = list.join("\u0000");35 const [order, setOrder] = React.useState<number[]>(() => list.map((_, i) => i));36 const busy = React.useRef(false);37 const x = useMotionValue(0);38 const rotate = useTransform(x, [-240, 240], [-18, 18]);39 const opacity = useTransform(x, [-360, -180, 0, 180, 360], [0, 1, 1, 1, 0]);40 41 React.useEffect(() => {42 setOrder(itemsKey.split("\u0000").map((_, i) => i));43 x.set(0);44 }, [itemsKey, x]);45 46 React.useEffect(() => () => x.stop(), [x]);47 48 const topIndex = order[order.length - 1] ?? 0;49 50 const commit = (direction: SwipeDirection) => {51 const index = topIndex;52 x.set(0);53 setOrder((o) => [o[o.length - 1] ?? 0, ...o.slice(0, -1)]);54 busy.current = false;55 onSwipe?.(list[index] ?? "", direction, index);56 };57 58 const fly = (direction: SwipeDirection) => {59 if (busy.current || order.length === 0) return;60 busy.current = true;61 if (prefersReducedMotion) {62 commit(direction);63 return;64 }65 animate(x, direction === "left" ? -420 : 420, { duration: 0.28, ease: [0.4, 0, 1, 1] }).then(() =>66 commit(direction),67 );68 };69 70 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {71 if (e.key === "ArrowLeft" || e.key === "ArrowRight") {72 e.preventDefault();73 fly(e.key === "ArrowLeft" ? "left" : "right");74 }75 };76 77 return (78 <div className={`${styles.root} ${className}`.trim()}>79 <div80 role="group"81 tabIndex={0}82 aria-roledescription="card stack"83 aria-label="Swipe cards. Use the left and right arrow keys to swipe the top card."84 onKeyDown={onKeyDown}85 className={styles.stack}86 >87 {order.map((itemIndex, i) => {88 const depth = order.length - 1 - i;89 const isTop = depth === 0;90 return (91 <motion.div92 key={`card-${itemIndex}`}93 aria-hidden={!isTop}94 className={styles.card}95 style={isTop ? { x, rotate, opacity, zIndex: 100 } : { zIndex: 100 - depth }}96 drag={isTop ? "x" : false}97 dragMomentum={false}98 onDragEnd={(_, info) => {99 if (!isTop || busy.current) return;100 if (Math.abs(info.offset.x) > threshold || Math.abs(info.velocity.x) > 700) {101 fly(info.offset.x < 0 ? "left" : "right");102 } else {103 animate(x, 0, { type: "spring", stiffness: 500, damping: 32 });104 }105 }}106 initial={false}107 animate={108 isTop109 ? { scale: 1, y: 0 }110 : {111 scale: 1 - Math.min(depth, VISIBLE) * 0.05,112 y: Math.min(depth, VISIBLE) * 10,113 opacity: depth < VISIBLE ? 1 : 0,114 }115 }116 transition={prefersReducedMotion ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 28 }}117 >118 <span className={styles.index}>119 {String(itemIndex + 1).padStart(2, "0")} / {String(list.length).padStart(2, "0")}120 </span>121 <span className={styles.label}>{list[itemIndex]}</span>122 <span className={styles.hint} aria-hidden="true">123 Drag or use arrow keys124 </span>125 </motion.div>126 );127 })}128 </div>129 <span className={styles.srOnly} aria-live="polite">130 {`Card ${topIndex + 1} of ${list.length}: ${list[topIndex] ?? ""}`}131 </span>132 {showControls ? (133 <div className={styles.controls}>134 <button type="button" className={styles.control} onClick={() => fly("left")} aria-label="Swipe left">135 <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">136 <path d="M15 6l-6 6 6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />137 </svg>138 </button>139 <button type="button" className={styles.control} onClick={() => fly("right")} aria-label="Swipe right">140 <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">141 <path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />142 </svg>143 </button>144 </div>145 ) : null}146 </div>147 );148}