feat(client): animate cards when they are reordered

Moving a card was an instant jump, with nothing to show what had changed
or where the card went.

Cards now travel between positions using FLIP — measure the old rect,
re-render in the new order, offset each card back to where it was, then
release it. This animates the real layout rather than faking movement, so
a card that shifts because *another* card moved animates too, and the
effect stays correct however the grid reflows. 260ms on a decelerating
curve: quick to leave, gentle to settle.

The card the user acted on is lifted above its neighbours for the
duration, so a swap reads as one card moving rather than two sliding
through each other.

No new dependency — @hello-pangea/dnd is drag-and-drop, not a layout
animation library, and the house style is plain CSS transitions.

Two things this deliberately avoids:

- Animating on anything but a reorder. The effect runs on every render,
  so it is gated behind an explicit capture taken at click time; the 30s
  SWR refresh, isStale toggles and picker changes all leave cards still.
- Leaving inline styles behind. Transition and z-index are cleared once
  settled, or a later unrelated reflow would animate the card again.

prefers-reduced-motion skips the animation entirely.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
This commit is contained in:
gorkem-bwl committed 2026-08-29 10:46:36 -04:00
1 parent 729c2cb049
commit a0d91abcd0
2 files changed
+121 -2

No files matched your search

@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { LAYOUT } from "@/Utils/Theme/constants";
import { GRID_COLUMNS, type CardDefinition, type CardId } from "../cards";
import { layoutCards } from "../layout";
import { useCardTransitions } from "../useCardTransitions";
import { CardReorderControls } from "./CardReorderControls";
import { CardSlotContext } from "./CardSlotContext";
@@ -17,6 +18,14 @@ export const CardGrid = ({
}) => {
const theme = useTheme();
const placed = useMemo(() => layoutCards(cards), [cards]);
const { registerCard, capturePositions } = useCardTransitions();
// Positions are measured before the state change so the cards can be
// animated from where they were to where they land.
const handleMove = (id: CardId, delta: -1 | 1) => {
capturePositions(id);
onMove(id, delta);
};
return (
<Box
@@ -35,8 +44,15 @@ export const CardGrid = ({
return (
<Box
key={card.id}
ref={(node: HTMLElement | null) => registerCard(card.id, node)}
minWidth={0}
// Positioned so the moving card can be lifted above its
// neighbours with a z-index while it travels.
position="relative"
gridColumn={{ xs: "auto", md: `span ${renderedWidth}` }}
// Hints the compositor before a transform is applied, so the
// first frame of a move does not stutter.
sx={{ willChange: "transform" }}
>
{/*
* The controls go through context rather than a prop so the 17
@@ -47,8 +63,8 @@ export const CardGrid = ({
<CardReorderControls
canMoveUp={index > 0}
canMoveDown={index < placed.length - 1}
onMoveUp={() => onMove(card.id, -1)}
onMoveDown={() => onMove(card.id, 1)}
onMoveUp={() => handleMove(card.id, -1)}
onMoveDown={() => handleMove(card.id, 1)}
/>
}
>
@@ -0,0 +1,103 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import type { CardId } from "./cards";
/** Long enough to follow a card across a row, short enough not to feel slow. */
const DURATION_MS = 260;
/** Decelerating: quick to leave, gentle to settle. */
const EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)";
/**
* Animates cards between layout positions using FLIP — First, Last, Invert,
* Play. The grid re-renders in its new order immediately; each card is then
* offset back to where it used to be and released, so it appears to travel
* from the old position to the new one.
*
* This animates the real layout rather than faking movement, so cards that
* shift because *another* card moved animate too, and the effect stays correct
* however the grid reflows.
*
* Honours prefers-reduced-motion by skipping the animation entirely.
*/
export const useCardTransitions = () => {
const nodes = useRef(new Map<CardId, HTMLElement>());
const positions = useRef(new Map<CardId, DOMRect>());
/** Set only for the render that follows a reorder, so nothing animates on data refresh. */
const isAnimating = useRef(false);
/** The card the user moved, lifted above the rest while it travels. */
const movedCard = useRef<CardId | null>(null);
const registerCard = useCallback((id: CardId, node: HTMLElement | null) => {
if (node) {
nodes.current.set(id, node);
} else {
nodes.current.delete(id);
}
}, []);
/**
* Call immediately before the state change that reorders the cards.
* `movedId` is the card the user acted on; it rides above the others so the
* swap reads as one card moving rather than two sliding through each other.
*/
const capturePositions = useCallback((movedId: CardId) => {
positions.current.clear();
for (const [id, node] of nodes.current) {
positions.current.set(id, node.getBoundingClientRect());
}
movedCard.current = movedId;
isAnimating.current = true;
}, []);
useLayoutEffect(() => {
if (!isAnimating.current) {
return;
}
isAnimating.current = false;
const reducedMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
)?.matches;
if (reducedMotion) {
positions.current.clear();
movedCard.current = null;
return;
}
for (const [id, node] of nodes.current) {
const previous = positions.current.get(id);
if (!previous) {
continue;
}
const current = node.getBoundingClientRect();
const deltaX = previous.left - current.left;
const deltaY = previous.top - current.top;
if (deltaX === 0 && deltaY === 0) {
continue;
}
// Invert: jump back to the old position with no transition...
node.style.transition = "none";
node.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
if (id === movedCard.current) {
node.style.zIndex = "1";
}
// ...then play: on the next frame, release to the new one.
requestAnimationFrame(() => {
node.style.transition = `transform ${DURATION_MS}ms ${EASING}`;
node.style.transform = "";
// Clear the inline styles once settled. Left in place they would
// animate the card again on any later reflow it did not ask for.
window.setTimeout(() => {
node.style.transition = "";
node.style.zIndex = "";
}, DURATION_MS);
});
}
positions.current.clear();
movedCard.current = null;
});
return { registerCard, capturePositions };
};