Components / Draggable Cards
Draggable Cards
A fun, interactive scattered photo gallery with draggable polaroid-style cards. Each card can be dragged freely within the container with elastic constraints. Cards automatically stack on top when clicked. Fully responsive with separate desktop and mobile sizing.
Preview
Fern UI.








Tip: Click and drag the cards to move them around. Cards stack on top when interacted with.
Usage
tsx
import { DraggableCards, DraggableCardItem } from "@/components/ui/draggable-cards"
const cards: DraggableCardItem[] = [
{
id: "card-1",
src: "/albums/cover.png",
alt: "Album Cover",
top: "20%",
left: "30%",
rotate: "6deg",
width: "w-56", // Desktop width
mobileWidth: "w-28", // Mobile width
},
// ... more cards
]
export function Example() {
return (
<DraggableCards
cards={cards}
title="My Gallery"
accentColor="text-orange-500"
/>
)
}Props
| Prop | Type | Description |
|---|---|---|
| cards | DraggableCardItem[] | Array of card objects to display |
| title | string? | Optional title text displayed in center |
| accentColor | string? | Tailwind color class for title dot (default: text-orange-500) |
| className | string? | Additional CSS classes for the container |
| cardsContainerClassName | string? | Additional CSS classes for the cards wrapper |
DraggableCardItem Type
| Property | Type | Description |
|---|---|---|
| id | string | Unique identifier for the card |
| src | string | Image source path |
| alt | string | Alt text for accessibility |
| top | string | CSS top position (e.g., "20%", "100px") |
| left | string | CSS left position (e.g., "30%", "50px") |
| rotate | string | CSS rotation (e.g., "6deg", "-12deg") |
| width | string? | Desktop width class (default: "w-48") |
| mobileWidth | string? | Mobile width class (default: "w-24") |
Features
- Draggable — Each card can be freely dragged with elastic constraints
- Auto-stacking — Cards automatically come to front when interacted with
- Responsive — Separate desktop and mobile sizing for each card
- Touch support — Full touch gesture support for mobile devices
- Theme aware — Adapts to both light and dark themes
- Hover effects — Subtle scale animations on hover and drag
Dependencies
This component requires the motion/react library for drag animations. Install it with:
bash
pnpm add motionSource Code
Copy this into your components/ui/ folder.
draggable-cards.tsxtsx
"use client";
import { motion } from "motion/react";
import { RefObject, useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < breakpoint);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, [breakpoint]);
return isMobile;
}
export interface DraggableCardItem {
id: string;
src: string;
alt: string;
top: string;
left: string;
rotate: string;
width?: string;
mobileWidth?: string;
}
interface DraggableCardsProps {
cards: DraggableCardItem[];
title?: string;
accentColor?: string;
className?: string;
cardsContainerClassName?: string;
}
export function DraggableCards({
cards,
title,
accentColor = "text-orange-500",
className,
cardsContainerClassName,
}: DraggableCardsProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const isMobile = useIsMobile();
return (
<section
className={cn(
"relative grid min-h-[500px] w-full place-content-center overflow-hidden bg-background md:min-h-screen",
className
)}
>
{title && (
<h1 className="pointer-events-none relative z-0 select-none text-center text-[12vw] font-black text-muted-foreground/50 md:text-[80px] lg:text-[100px]">
{title}
<span className={accentColor}>.</span>
</h1>
)}
<div
ref={containerRef}
className={cn("absolute inset-0 z-10", cardsContainerClassName)}
>
{cards.map((card) => (
<DraggableCard
key={card.id}
containerRef={containerRef}
src={card.src}
alt={card.alt}
rotate={card.rotate}
top={card.top}
left={card.left}
className={cn(
isMobile ? (card.mobileWidth || "w-24") : (card.width || "w-48")
)}
/>
))}
</div>
</section>
);
}
type DraggableCardProps = {
containerRef: RefObject<HTMLDivElement | null>;
src: string;
alt: string;
top: string;
left: string;
rotate: string;
className?: string;
};
function DraggableCard({
containerRef,
src,
alt,
top,
left,
rotate,
className,
}: DraggableCardProps) {
const [zIndex, setZIndex] = useState(0);
const updateZIndex = useCallback(() => {
const elements = document.querySelectorAll(".draggable-card-item");
let maxZIndex = -Infinity;
elements.forEach((el) => {
const currentZIndex = parseInt(
window.getComputedStyle(el).getPropertyValue("z-index")
);
if (!isNaN(currentZIndex) && currentZIndex > maxZIndex) {
maxZIndex = currentZIndex;
}
});
setZIndex(maxZIndex + 1);
}, []);
return (
<motion.img
onMouseDown={updateZIndex}
onTouchStart={updateZIndex}
src={src}
alt={alt}
drag
dragConstraints={containerRef}
dragElastic={0.65}
whileDrag={{ scale: 1.05 }}
whileHover={{ scale: 1.02 }}
className={cn(
"draggable-card-item absolute cursor-grab bg-card p-1 pb-4 shadow-lg ring-1 ring-foreground/10 transition-shadow hover:shadow-xl active:cursor-grabbing dark:ring-foreground/20",
className
)}
style={{
top,
left,
rotate,
zIndex,
}}
/>
);
}