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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/elastic-drag-card.jsonPrompt
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 event —
onSettlefires 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.
Holographic Foil Card
A card surface that behaves like trading-card holo stock — an iridescent sheet, a fine foil grating and a specular highlight all re-aimed from the pointer.
Magnetic Grid
A lattice of tiles that leans toward — or away from — the cursor, resolved for every tile from a single pointer listener with distance falloff.