Mikkel Manniche
Dansk English Deutsch

Surfaces

Card swipe

Cards that turn away like pages when you swipe, drag or use the arrows.

Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.

Preview

Usage

card-swipe-demo.tsx
import { Coffee, Leaf, Shirt, Sparkles } from 'lucide-react'
import { CardSwipe } from '@/components/card-swipe'

const picks = [
  { icon: Shirt, title: 'Linen basics', text: 'Light shirts and trousers for warm days.' },
  { icon: Leaf, title: 'Plant care', text: 'Pots, soil and a watering can that pours right.' },
  { icon: Coffee, title: 'Slow mornings', text: 'A hand grinder, a dripper and good beans.' },
  { icon: Sparkles, title: 'Gift ideas', text: 'Small things people actually keep.' },
]

export default function CardSwipeDemo() {
  return (
    <CardSwipe
      label="Collections"
      cards={picks.map(({ icon: Icon, title, text }) => (
        <div key={title} className="flex h-80 flex-col items-start rounded-[32px] border bg-card p-7">
          <span className="mb-8 grid size-16 place-items-center rounded-2xl border bg-card shadow-md">
            <Icon className="size-8" strokeWidth={1.5} aria-hidden />
          </span>
          <h3 className="text-2xl font-semibold">{title}</h3>
          <p className="mt-2 mb-auto text-muted-foreground">{text}</p>
          <a href="#shop" className="rounded-full bg-foreground px-5 py-2.5 text-sm text-background">
            Shop the edit
          </a>
        </div>
      ))}
    />
  )
}

Source

card-swipe.tsx
// Based on Watermelon UI's “Card swipe” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with buttons, arrow keys, dots and a width that follows the container.
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { MotionConfig, motion, useMotionValue, useTransform, type MotionValue, type PanInfo } from 'motion/react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type CardSwipeProps = {
  /** One node per card. */
  cards: ReactNode[]
  label?: string
  previousLabel?: string
  nextLabel?: string
  className?: string
}

const GAP = 16

/** Cards in a row that turn away like pages as you swipe, drag, click or use the arrow keys. */
export function CardSwipe({ cards, label = 'Cards', previousLabel = 'Previous', nextLabel = 'Next', className }: CardSwipeProps) {
  const [index, setIndex] = useState(0)
  const [width, setWidth] = useState(320)
  const frame = useRef<HTMLDivElement>(null)
  const x = useMotionValue(0)
  const last = cards.length - 1

  useEffect(() => {
    const el = frame.current
    if (!el) return
    const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width))
    ro.observe(el)
    return () => ro.disconnect()
  }, [])

  const go = (i: number) => setIndex(Math.max(0, Math.min(last, i)))

  const end = (_: unknown, info: PanInfo) => {
    if (info.offset.x < -50 || info.velocity.x < -500) go(index + 1)
    else if (info.offset.x > 50 || info.velocity.x > 500) go(index - 1)
  }

  const arrow = 'grid size-11 place-items-center rounded-full border bg-card shadow-sm transition-opacity duration-150 disabled:opacity-30'

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 330, damping: 30 }}>
      <section
        aria-roledescription="carousel"
        aria-label={label}
        className={cn('mx-auto flex w-full max-w-xs flex-col items-center gap-4', className)}
        onKeyDown={(e) => {
          if (e.key === 'ArrowRight') go(index + 1)
          if (e.key === 'ArrowLeft') go(index - 1)
        }}
      >
        <div ref={frame} className="w-full overflow-hidden">
          <motion.div
            drag="x"
            dragConstraints={{ left: -(width + GAP) * last, right: 0 }}
            onDragEnd={end}
            animate={{ x: -index * (width + GAP) }}
            style={{ x, gap: GAP, perspective: 1000 }}
            className="flex cursor-grab touch-pan-y active:cursor-grabbing"
          >
            {cards.map((card, i) => (
              <Card key={i} i={i} x={x} step={width + GAP} width={width} current={i === index} total={cards.length}>
                {card}
              </Card>
            ))}
          </motion.div>
        </div>
        <div className="flex items-center gap-4">
          <button type="button" aria-label={previousLabel} disabled={index === 0} onClick={() => go(index - 1)} className={arrow}>
            <ChevronLeft className="size-5" aria-hidden />
          </button>
          <div className="flex gap-1.5" aria-hidden>
            {cards.map((_, i) => (
              <motion.span key={i} animate={{ width: i === index ? 18 : 6 }} className={cn('h-1.5 rounded-full', i === index ? 'bg-foreground' : 'bg-border')} />
            ))}
          </div>
          <button type="button" aria-label={nextLabel} disabled={index === last} onClick={() => go(index + 1)} className={arrow}>
            <ChevronRight className="size-5" aria-hidden />
          </button>
        </div>
      </section>
    </MotionConfig>
  )
}

type CardProps = { i: number; x: MotionValue<number>; step: number; width: number; current: boolean; total: number; children: ReactNode }

function Card({ i, x, step, width, current, total, children }: CardProps) {
  // Turns 90° as it slides one full step away in either direction.
  const rotateY = useTransform(x, [-(i + 1) * step, -i * step, -(i - 1) * step], [90, 0, -90], { clamp: false })
  return (
    <motion.div
      role="group"
      aria-roledescription="slide"
      aria-label={`${i + 1} / ${total}`}
      aria-hidden={!current}
      inert={!current}
      style={{ width, rotateY }}
      className="shrink-0 select-none"
    >
      {children}
    </motion.div>
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/card-swipe.json

With the registry added (components.json)

npx shadcn@latest add @manniche/card-swipe

Files

  • card-swipe.tsx

Manniche Lab · open source

Free tools for AI agents and web development.