From a0d91abcd021804055d4cfdccd71a4874b8a30cb Mon Sep 17 00:00:00 2001 From: gorkem-bwl Date: Sat, 29 Aug 2026 10:46:36 -0400 Subject: [PATCH] feat(client): animate cards when they are reordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Pages/Dashboard/Components/CardGrid.tsx | 20 +++- .../src/Pages/Dashboard/useCardTransitions.ts | 103 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 client/src/Pages/Dashboard/useCardTransitions.ts diff --git a/client/src/Pages/Dashboard/Components/CardGrid.tsx b/client/src/Pages/Dashboard/Components/CardGrid.tsx index 000b94a80..a2be301d1 100644 --- a/client/src/Pages/Dashboard/Components/CardGrid.tsx +++ b/client/src/Pages/Dashboard/Components/CardGrid.tsx @@ -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 ( 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 = ({ 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)} /> } > diff --git a/client/src/Pages/Dashboard/useCardTransitions.ts b/client/src/Pages/Dashboard/useCardTransitions.ts new file mode 100644 index 000000000..e4003ef92 --- /dev/null +++ b/client/src/Pages/Dashboard/useCardTransitions.ts @@ -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()); + const positions = useRef(new Map()); + /** 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(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 }; +};