Drag Card

Draggable card with elastic constraints and a spring return.

Working demo
npx shadcn@latest add https://noechague-site.vercel.app/r/drag-card.json
drag-card.tsx
"use client"

import { useRef, type ReactNode } from "react"
import { motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"

export function DragCard({ children }: { children?: ReactNode }) {
  const constraints = useRef<HTMLDivElement>(null)
  const x = useMotionValue(0)
  const rotate = useTransform(x, [-160, 160], [-12, 12])
  const reduced = useReducedMotion() ?? false

  return (
    <div className="flex h-full w-full items-center justify-center p-6" ref={constraints}>
      <motion.button
        // No aria-label. It used to read "Move card" while the button showed
        // "Drag me": WCAG 2.5.3 requires the accessible name to contain the
        // visible label, and voice control users saying "click Drag me" hit
        // nothing. The visible text is the name now.
        // Stock Tailwind classes only: this file is installed into other
        // people's projects, where the site's own scale (gray-1100, preview-bg,
        // shadow-custom) does not exist and silently renders as nothing.
        // neutral-500 for the border, not a lighter step: the border is the
        // only thing that says where the card is, and WCAG 1.4.11 asks 3:1 for
        // that. It measures 4.74:1 on white, 4.35:1 on the card fill.
        className="flex size-32 cursor-grab items-center justify-center rounded-3xl border border-neutral-500 bg-neutral-100 text-neutral-600 active:cursor-grabbing"
        drag
        dragConstraints={constraints}
        // Under prefers-reduced-motion the drag itself stays: it is the whole
        // point of the component. What goes is the decoration, namely elastic
        // overshoot (so nothing has to spring back), release inertia
        // (`power: 0` stops dead), rotation, and the scale pop.
        dragElastic={reduced ? 0 : 0.18}
        dragTransition={reduced ? { power: 0 } : { bounceStiffness: 320, bounceDamping: 28 }}
        style={reduced ? undefined : { x, rotate }}
        type="button"
        whileDrag={reduced ? undefined : { scale: 1.04 }}
      >
        {children ?? "Drag me"}
      </motion.button>
    </div>
  )
}