Display

Elastic Drag Card

A card you can grab and fling — it chases the pointer on a spring, tilts with its own drag velocity, and springs home or docks into a snap point on release.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"
const COARSE_POINTER = "(pointer: coarse)"

/** The spring is integrated at a fixed 120Hz sub-step, so a 60Hz and a 144Hz screen settle identically. */
const SUB_STEP = 1 / 120
/** A backgrounded tab hands back one enormous delta on return; clamping it stops the integrator exploding. */
const MAX_FRAME = 0.064
/** Rest test: nearer than this to the target and slower than this, the spring is done. */
const REST_DISTANCE = 0.4

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/elastic-drag-card.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ElasticDragCard" component: a card that is
grabbed, thrown and springs back (no runtime dependency beyond React and the shared
cn() utility — the physics is ~40 lines, a motion library buys nothing here).

Contract
- Export a forwardRef<HTMLDivElement, ElasticDragCardProps> extending
  React.HTMLAttributes<HTMLDivElement>; the card IS the root, so className, ref and
  the remaining props all land on the element that moves.
- Props: stiffness = 220, damping = 22 (about 2*sqrt(stiffness) is critical — below
  it the card overshoots), maxRotation = 12 (degrees at full drag speed),
  axis = "both" | "x" | "y", maxDistance = 140 (px of travel per axis),
  snapPoints = [] of { id, x, y, label? } where x/y are px offsets from the resting
  origin, snapRadius = 96, keyboardStep = 24, disabled = false,
  label?: string with NO default (see ARIA), onSettle?: (point | null) => void.
- children are arbitrary: the component owns the gesture and the card surface,
  never the content.
- The component owns its position; there is no controlled x/y. onSettle is the only
  output, and it reports where the card came to REST, not where it was let go.

Behavior
- One spring integrator drives everything. The pointer never writes the position:
  it moves a target, and the card chases it with
  a = -stiffness * (pos - target) - damping * vel. Elastic lag, the tilt and the
  fling on release are all consequences of that one loop, not three effects.
- Integrate at a fixed 1/120s sub-step inside each frame, and clamp the frame delta
  to ~64ms, so 60Hz and 144Hz settle identically and a backgrounded tab cannot hand
  back one huge delta that blows the integrator up.
- Tilt = clamp(velocityX * 0.035, -maxRotation, maxRotation) degrees. It builds while
  the card is thrown and is forced to exactly 0 at rest, so the card never parks
  crooked.
- Drag: pointerdown does setPointerCapture, so the gesture survives the pointer
  leaving the box — which is exactly what a fling is. Store the grab origin and the
  card's position at grab time, and rebase the spring target onto that live position
  while zeroing the velocity, so a card grabbed mid-flight stops dead under the finger
  instead of finishing its old throw and then jumping back on the first move (on touch,
  50-100ms between pointerdown and the first move makes that the normal case); each
  pointermove writes the axis-constrained, maxDistance-clamped target into a REF and
  asks for one animation frame. No state is set and no DOM is read per move, so a
  1000Hz mouse still costs one paint per frame. pointercancel and lostpointercapture
  settle the card instead of stranding it.
- Release: project the position along its velocity by ~0.12s, then pick the nearest
  candidate. Home (0, 0) is always a candidate and wins ties, so a slow release near
  the middle — or a plain click that never moved — returns home, while a hard throw
  reaches a snap point inside snapRadius. Snap coordinates are re-clamped to axis.
- onSettle fires from the loop when the spring reaches rest (distance < 0.4px and
  speed < 8px/s), never from pointerup, and it carries the snap point or null.
- Keyboard parity: the card is tabbable, arrow keys move the target by keyboardStep
  (a locked axis leaves its arrows to the page so scrolling is never swallowed),
  Enter / Space releases, Escape / Home returns it to the start, and blurring while
  nudged releases it too — a card must never be stranded off-centre with nothing
  focused to explain it. Ignore keys that bubbled from a control inside the card,
  and any Ctrl / Meta / Alt chord.
- prefers-reduced-motion, read through useSyncExternalStore so an OS change
  mid-session is honoured: dragging still works and every callback is identical —
  the card is simply written straight onto the target each frame, with no tilt and
  no scale. Nothing is animation-gated, so nothing can end up stuck.
- Coarse pointers: a finger cannot give up vertical panning without losing the page.
  "both" degrades to horizontal-only, axis="y" turns pointer dragging off and leaves
  the keyboard path, and the element carries touch-action: pan-y in every draggable
  configuration — the card never claims a vertical finger.
- disabled renders a plain static card: no role, no tab stop, no live region, no
  handlers, and no dimming that would cost the content its contrast. There is no
  control to disable here, only a card that no longer moves — so it is neither a
  dead tab stop nor an aria-disabled widget.
- Cleanup: exactly one rAF handle and one status-clearing timeout, both cancelled on
  unmount; both media-query listeners are removed by their subscribe cleanups. There
  are no window listeners and no observers. Skip will-change — permanently promoting
  the card to its own layer is what makes its text look fuzzy.

Rendering & styling
- Surface: rounded-xl border bg-card text-card-foreground shadow-sm, select-none,
  cursor-grab / cursor-grabbing, shadow-xl while held, focus-visible:ring-2
  ring-ring ring-offset-2. Semantic tokens only — no hex, no rgb().
- The held card also scales to ~1.04, chased by the same loop (a lerp at ~12/s) so
  the grab has weight instead of snapping on.
- One decorative layer: an aria-hidden, pointer-events-none span, absolute inset-0
  rounded-[inherit], painted with
  radial-gradient(..., color-mix(in oklab, var(--primary) 20%, transparent)) and
  faded in only while the card is held. It sits at -z-10 under an `isolate` root, so
  it paints over the card background and under the content.
- The transform is written imperatively as
  translate3d(x, y, 0) rotate(deg) scale(n) — one string, one node, no per-frame
  React render.
- ARIA: role="group" with aria-roledescription="Draggable card", so the content inside
  stays readable (role="button" would flatten it into a name). aria-label is only what
  the consumer passes — it must NOT default to "Draggable card", or an unnamed card is
  announced as "Draggable card, Draggable card", since the name is read out and then the
  role description. aria-describedby points at an sr-only line naming the keys; one
  sr-only role="status" aria-live="polite" region announces every nudge and every settle,
  and each sentence clears itself after ~4s — an identical string never mutates the live
  region, so without the clear the second "Back at the start." would be silent, which is
  the outcome this card produces most often.

Customization levers
- Feel: stiffness 120 / damping 20 is soft and heavy; 220 / 22 is the default
  "quality app" spring; 520 / 14 is taut and whippy with visible overshoot. Damping
  near 2*sqrt(stiffness) removes the bounce entirely.
- Personality: maxRotation 0 keeps the card upright (good for text-heavy cards),
  20+ makes it read as a thrown object. The 0.035 deg-per-px/s constant is how fast
  the tilt saturates.
- Reach: maxDistance is the leash — small values make the card feel rubber-banded to
  its slot, large values make it feel free.
- Snapping: snapPoints turns a toy into a control (dock left/right, a 2D board, a
  single "confirm" pocket); snapRadius decides how magnetic those slots are, and
  the 0.12s projection decides how much a fling counts versus where the release
  happened.
- Surface: override rounded / border / bg / padding through className; the sheen
  inherits the radius, so nothing else needs updating. Drop the sheen span for a
  flat card.
- Wiring: onSettle is where an archive mutation fires, a slot is persisted, or a
  gesture is logged — the component never fetches or stores anything itself.

Concepts

  • Spring target, not spring position — the pointer only moves the target the card is chasing; the lag, the throw and the return are one integrator's output, which is why they always agree with each other.
  • Velocity as expression — the tilt is read off the same velocity that drives the motion, so the card leans into a fast flick and stays upright on a careful drag, without a second animation to keep in sync.
  • Fling projection — a release is extrapolated along its velocity before the nearest slot is chosen, so "throw it at the archive" works while a slow drop still falls home; home competing on distance is what stops a click from teleporting the card.
  • Settle is a physical eventonSettle fires when the spring actually stops, so the parent hears "it landed here", not "the finger left at these coordinates".
  • A throw can be caught — grabbing pins the card where it is by rebasing the target onto its live position and dropping the velocity, so a card still in flight stops under the finger instead of sliding away to finish the previous throw.
  • rAF-throttled gesture — pointer handlers only write refs and request a frame; every DOM write happens once per frame inside the loop, and the single rAF handle is cancelled on unmount.
  • Reduced motion keeps the verb — with motion off the card is placed exactly on its target each frame: the drag, the snapping and every callback survive, only the animation is gone.
  • Keyboard is a first path — arrows nudge, Enter releases, Escape goes home, and blur releases anything left held, so the same settle logic serves both input models instead of the keyboard getting a stub.

On This Page