Studio KitHigh-End Animated Cardsmotion/react

SwipeCards

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

05 / 05Aurora
Card 5 of 5: Aurora

Customize

3 props
100

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add swipe-cards

Writes 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.

example.tsx
1import { SwipeCards } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <SwipeCards />6  );7}

Props

PropTypeDefault
items

Card labels; the last item starts on top. Duplicates are supported.

string[]['Kinetic Type', 'Liquid Glass', 'Magnetic Dock', 'Holo Tilt', 'Aurora']
threshold

Horizontal drag distance in pixels that commits a swipe.

number100
showControls

Show the previous/next buttons below the stack.

booleantrue
onSwipe

Called after a card is swiped away.

(item: string, direction: 'left' | 'right', index: number) => voidundefined
className

Extra class merged onto the root.

string''

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

components/ui/swipe-cards.tsx
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}