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.

Blonde AlbumCollege Dropout AlbumGraduation AlbumMBDTF AlbumTLOP AlbumYeezus AlbumGNX AlbumNWTS Album

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

PropTypeDescription
cardsDraggableCardItem[]Array of card objects to display
titlestring?Optional title text displayed in center
accentColorstring?Tailwind color class for title dot (default: text-orange-500)
classNamestring?Additional CSS classes for the container
cardsContainerClassNamestring?Additional CSS classes for the cards wrapper

DraggableCardItem Type

PropertyTypeDescription
idstringUnique identifier for the card
srcstringImage source path
altstringAlt text for accessibility
topstringCSS top position (e.g., "20%", "100px")
leftstringCSS left position (e.g., "30%", "50px")
rotatestringCSS rotation (e.g., "6deg", "-12deg")
widthstring?Desktop width class (default: "w-48")
mobileWidthstring?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 motion

Source 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,
            }}
        />
    );
}