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.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/card-swipe