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:
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 };
|
||||
};
|
||||
Reference in new issue
Block a user