feat(chess): spring-physics drag with cursor-release FLIP animation

Dragging a piece now follows the cursor with spring lag and a subtle tilt,
scales up with a deeper shadow while lifted, and on a valid drop glides
smoothly from the cursor-release position into the destination square.
Invalid drops spring back to the origin. Dragging is disabled entirely
for the non-playing side (no grab cursor, no transforms, no drag events).

Cursor tracking uses a document-level `dragover` listener — the `drag`
event on the source element is throttled by Chromium and reports 0/0 in
Firefox, so is unusable for smooth tracking.

Replaces motion`s `layoutId` FLIP with a manual implementation. Motion
measures layout rects without inline transforms, so `layoutId` always
animated from the source square instead of the cursor position. The new
approach stashes the transformed `getBoundingClientRect` on dragend and
consumes it from a `useLayoutEffect` at mount, jumping the spring to the
delta and letting it animate home — producing a true release-to-target
FLIP.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-17 13:30:36 -06:00
commit d4622b2cb4
No known key found for this signature in database
2 changed files with 299 additions and 48 deletions

View file

@ -152,26 +152,29 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
/>
)}
<AnimatePresence>
{piece && (
<motion.div
key={piece.id}
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0, transition: { duration: 0.2 } }}
className="absolute inset-0 z-20"
>
<Piece
color={piece.color}
type={piece.type}
pieceId={piece.id}
square={sq}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</motion.div>
)}
</AnimatePresence>
{/*
* No AnimatePresence wrapper here: the `<Piece>` component runs
* its own FLIP across unmount (at source) and mount (at target)
* via a module-level rect stash. An AnimatePresence exit
* animation on the source wrapper would show a ghost of the old
* piece at its origin while the new mount springs in from the
* cursor position competing artifacts. Captures pop instantly
* without a fade, which is acceptable and keeps the movement
* FLIP clean.
*/}
{piece && (
<div key={piece.id} className="absolute inset-0 z-20">
<Piece
color={piece.color}
type={piece.type}
pieceId={piece.id}
square={sq}
isDraggable={piece.color === turn}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
</div>
)}
{/* Rank/File labels (optional but helpful for debug) */}
{f === 0 && (
@ -191,7 +194,7 @@ export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSq
return (
<div className="relative">
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] overflow-hidden bg-neutral-900 border-4 border-neutral-800">
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] bg-neutral-900 border-4 border-neutral-800">
{squares}
</div>

View file

@ -1,6 +1,12 @@
import type { PieceColor, PieceType } from '../schema';
import { pieceAssets } from '../assets/pieces';
import { motion } from 'motion/react';
import {
motion,
useMotionValue,
useSpring,
useTransform,
} from 'motion/react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { DragEvent as ReactDragEvent } from 'react';
export interface PieceProps {
@ -8,59 +14,301 @@ export interface PieceProps {
type: PieceType;
pieceId: number;
square: number;
/** Whether this piece can be picked up right now (its color's turn, game
* ongoing, etc). When false, drag is disabled and the piece shows a
* default cursor. */
isDraggable: boolean;
onDragStart: (pieceId: number, square: number) => void;
onDragEnd: () => void;
}
/**
* Module-level registry that bridges a piece's unmount-at-old-square and
* mount-at-new-square across a valid drop.
*
* Why not motion's `layoutId` FLIP? `layoutId` measures each element's
* *layout* rect i.e. the rect as if no inline transforms were applied.
* Since our cursor-following juice is implemented via `x`/`y` motion
* values (inline transforms), motion's FLIP source rect is always the
* piece's original square, not the cursor's release position. The animation
* looks like a teleport-then-glide.
*
* We do FLIP by hand instead:
* 1. In `handleDragEnd`, snapshot the piece's *rendered* center (which
* includes the transform) into this map, keyed by `pieceId`.
* 2. React updates the board, unmounting the old `<Piece>` and mounting
* a fresh one at the target square (same `pieceId`, new `square`).
* 3. The new piece's `useLayoutEffect` finds the stashed center, measures
* its own mount center, computes the delta, and jumps the springs to
* that delta without animation. Setting the spring targets back to 0
* then animates the piece from the cursor's release position to home.
*
* On an invalid drop the same `<Piece>` instance stays mounted no new
* mount consumes the stash so we clear stale entries on the next
* dragstart and after a grace period.
*/
const pendingDropRects = new Map<number, { cx: number; cy: number }>();
/**
* Piece component.
*
* We wrap the draggable DOM node in an outer `motion.div` that owns the
* FLIP layout animation (via `layoutId`). The inner plain `<div>` owns the
* HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the
* browser's DnD subsystem both dispatch events on this node. Keeping the
* two responsibilities separate avoids a type clash `motion.div`'s
* `onDragStart` prop is typed for its own pointer-based drag gesture,
* which is incompatible with `React.DragEvent`. The extra DOM node costs
* nothing and sidesteps the cast.
* Three-layer structure:
*
* 1. Outer plain `<div>` stable bounding box for Playwright's
* actionability check. A `motion.div` here whose descendants are
* spring-animating would register as "not stable" and cause `dragTo`
* to time out.
*
* 2. Middle plain `<div draggable>` owns HTML5 native drag-and-drop.
* The browser's DnD subsystem and Playwright's `locator.dragTo()` both
* dispatch events against this node.
*
* 3. Inner `motion.div` cursor-following transform (`x`/`y`/`rotate`)
* plus scale/shadow lift. `pointer-events: none` while dragging so
* the translated piece doesn't block `dragover` from reaching the
* target square beneath it.
*
* Cursor tracking: while dragging we attach a document-level `dragover`
* listener. That event fires at a high rate with accurate `clientX/Y` in
* every browser unlike `drag` on the source element, which is throttled
* by Chromium and reports 0/0 in Firefox. Cursor delta feeds `xRaw`/`yRaw`,
* smoothed through a spring (`x`/`y`) so the piece trails with weight. A
* `useTransform` over the raw-vs-smoothed gap drives a tilt.
*/
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
export function Piece({
color,
type,
pieceId,
square,
isDraggable,
onDragStart,
onDragEnd,
}: PieceProps) {
const imgSrc = pieceAssets[color][type];
// Raw pointer offset from drag origin, updated on every `dragover`.
const xRaw = useMotionValue(0);
const yRaw = useMotionValue(0);
// Smoothed offset — what the piece actually renders at. The spring lag
// gives the piece weight while dragging and drives both the snap-back
// (invalid drop) and the mount-FLIP (valid drop).
const springCfg = { stiffness: 350, damping: 28, mass: 0.6 };
const x = useSpring(xRaw, springCfg);
const y = useSpring(yRaw, springCfg);
// Tilt is proportional to the raw-vs-smoothed X gap, clamped subtle.
const rotate = useTransform<number, number>([xRaw, x], ([rawVal, smoothVal]) => {
const delta = (rawVal as number) - (smoothVal as number);
return Math.max(-25, Math.min(25, delta * 0.35));
});
const transformRef = useRef<HTMLDivElement>(null);
const originRef = useRef<{ x: number; y: number } | null>(null);
const dragOverHandlerRef = useRef<((e: globalThis.DragEvent) => void) | null>(null);
const snapBackRafRef = useRef<number | null>(null);
const staleStashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const [isDragging, setIsDragging] = useState(false);
const detachDragOver = () => {
if (dragOverHandlerRef.current) {
document.removeEventListener('dragover', dragOverHandlerRef.current);
dragOverHandlerRef.current = null;
}
};
// Mount effect: consume a pending drop rect for this pieceId, if one was
// stashed by the piece that just unmounted at the old square. We measure
// the new mount's center and jump the springs to the delta so the piece
// appears at the cursor-release position, then let them animate to 0.
//
// `useLayoutEffect` runs synchronously after mutation and before paint,
// so there's no visible flash of the piece at the new square before the
// FLIP begins.
useLayoutEffect(() => {
const stash = pendingDropRects.get(pieceId);
if (!stash) return;
pendingDropRects.delete(pieceId);
const node = transformRef.current;
if (!node) return;
const nextRect = node.getBoundingClientRect();
const nextCx = nextRect.left + nextRect.width / 2;
const nextCy = nextRect.top + nextRect.height / 2;
const dx = stash.cx - nextCx;
const dy = stash.cy - nextCy;
// `.jump()` snaps the spring's current value without triggering an
// animation. Setting `xRaw`/`yRaw` back to 0 re-targets the spring,
// so it animates from `delta` to `0` — a FLIP from release to home.
x.jump(dx);
y.jump(dy);
xRaw.set(0);
yRaw.set(0);
// Intentionally only re-run when pieceId changes: x/y/xRaw/yRaw are
// stable motion-value references and this effect is about the initial
// mount animation.
}, [pieceId]);
// Mount/unmount tracking. Empty deps by design: this only wires cleanup
// on unmount, regardless of prop changes.
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (snapBackRafRef.current !== null) {
cancelAnimationFrame(snapBackRafRef.current);
snapBackRafRef.current = null;
}
if (staleStashTimerRef.current !== null) {
clearTimeout(staleStashTimerRef.current);
staleStashTimerRef.current = null;
}
detachDragOver();
};
}, []);
const handleDragStart = (e: ReactDragEvent<HTMLDivElement>) => {
if (!isDraggable) {
// Belt-and-suspenders: `draggable={false}` on the DOM should already
// prevent this, but guard against prop-flip races between render
// and event dispatch.
e.preventDefault();
return;
}
// Cancel any in-flight snap-back from a previous drag (quick re-drag).
if (snapBackRafRef.current !== null) {
cancelAnimationFrame(snapBackRafRef.current);
snapBackRafRef.current = null;
}
// Clear any stale stashed drop rect for this piece (e.g. from a prior
// invalid drop that never got consumed by a remount).
pendingDropRects.delete(pieceId);
if (staleStashTimerRef.current !== null) {
clearTimeout(staleStashTimerRef.current);
staleStashTimerRef.current = null;
}
e.dataTransfer.effectAllowed = 'move';
// Firefox requires non-empty drag data for dragstart to take effect.
e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
// Suppress the browser's default ghost image — we'd rather the piece
// stay visually in place while motion handles the move animation.
// Suppress the browser's default ghost image — we render our own
// dangling piece via the motion layer.
const img = new Image();
img.src =
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
e.dataTransfer.setDragImage(img, 0, 0);
originRef.current = { x: e.clientX, y: e.clientY };
xRaw.set(0);
yRaw.set(0);
setIsDragging(true);
// Attach a document-level dragover listener. Unlike `drag` on the
// source, `dragover` fires on every pointer movement with accurate
// coordinates across Chromium, Firefox, and WebKit.
detachDragOver();
const onDocDragOver = (ev: globalThis.DragEvent) => {
if (!originRef.current) return;
if (ev.clientX === 0 && ev.clientY === 0) return;
xRaw.set(ev.clientX - originRef.current.x);
yRaw.set(ev.clientY - originRef.current.y);
};
document.addEventListener('dragover', onDocDragOver);
dragOverHandlerRef.current = onDocDragOver;
onDragStart(pieceId, square);
};
const handleDragEnd = () => {
detachDragOver();
originRef.current = null;
setIsDragging(false);
// Snapshot the piece's *rendered* center (including the cursor-offset
// transform) BEFORE notifying the parent, because `onDragEnd` is
// synchronous and React will apply the new board state — unmounting
// us — as soon as we return. The rect has to be captured while the
// transformed element still exists in the DOM.
const node = transformRef.current;
if (node) {
const rect = node.getBoundingClientRect();
pendingDropRects.set(pieceId, {
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
});
// Grace period for invalid drops: if no remount consumes the stash
// within a few frames, clear it so a later unrelated mount doesn't
// receive a stale FLIP.
if (staleStashTimerRef.current !== null) {
clearTimeout(staleStashTimerRef.current);
}
staleStashTimerRef.current = setTimeout(() => {
pendingDropRects.delete(pieceId);
staleStashTimerRef.current = null;
}, 100);
}
// Notify parent. On a valid drop this triggers the unmount/remount
// that our `useLayoutEffect` consumes (via `pendingDropRects`).
onDragEnd();
// Defer the spring snap-back for invalid drops. If React unmounts us
// (valid drop), `mountedRef.current` is false and we skip the reset
// — the new mount handles the animation via the stashed rect. If we
// survive (invalid drop), zero the motion values so the spring
// animates the piece back to its origin square.
snapBackRafRef.current = requestAnimationFrame(() => {
snapBackRafRef.current = requestAnimationFrame(() => {
snapBackRafRef.current = null;
if (!mountedRef.current) return;
xRaw.set(0);
yRaw.set(0);
});
});
};
const cursorClass = isDraggable
? 'cursor-grab active:cursor-grabbing'
: 'cursor-default';
return (
<motion.div
layoutId={`piece-${pieceId}`}
className="w-full h-full"
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<div className="w-full h-full">
<div
draggable
draggable={isDraggable}
data-piece={`${color}-${type}`}
data-piece-id={pieceId}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
className="flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing select-none"
onDragEnd={handleDragEnd}
className={`flex items-center justify-center w-full h-full select-none ${cursorClass}`}
>
<img
src={imgSrc}
alt={`${color} ${type}`}
className="w-[85%] h-[85%] drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)] pointer-events-none"
draggable={false}
/>
<motion.div
ref={transformRef}
className={`flex items-center justify-center w-full h-full ${isDragging ? 'pointer-events-none' : ''}`}
style={{ x, y, rotate }}
animate={{
scale: isDragging ? 1.15 : 1,
zIndex: isDragging ? 50 : 0,
}}
transition={{
scale: { type: 'spring', stiffness: 500, damping: 30 },
zIndex: { duration: 0 },
}}
>
<img
src={imgSrc}
alt={`${color} ${type}`}
className={`w-[85%] h-[85%] pointer-events-none transition-[filter] duration-200 ${
isDragging
? 'drop-shadow-[0_12px_16px_rgba(0,0,0,0.45)]'
: 'drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)]'
}`}
draggable={false}
/>
</motion.div>
</div>
</motion.div>
</div>
);
}