import { useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, RefObject } from 'react' // useCardAnimations animates a keyed row of cards laid out in a flex container: // // • MOVE — when the order changes (dragging to reorder) or a neighbor is // added/removed, the surviving cards glide from their old slot to the new // one using the FLIP technique (measure, invert, play). // • ENTER — a card that appears animates in. `data-enter="flip"` reads as the // card being flipped face-up off the deck; `data-enter="pop"` springs it in. // • EXIT — a card that disappears is pinned where it sat and shrinks away as // a "ghost" (the live element is already gone, so React can't hold it), then // is dropped once the exit finishes. Meanwhile the MOVE pass slides its // former neighbors into the gap it left. // // Only children carrying `data-flip-key` take part; placeholders (empty shop // slots) are ignored. The consumer renders `ghosts` inside the same container. // Honors prefers-reduced-motion by recording positions but skipping animation. const MOVE_MS = 300 const ENTER_FLIP_MS = 440 const ENTER_POP_MS = 320 const EXIT_MS = 280 const MOVE_EASE = 'cubic-bezier(0.34, 1.4, 0.5, 1)' const ENTER_EASE = 'cubic-bezier(0.2, 0.8, 0.3, 1)' const STAGGER_MS = 55 export interface Ghost { key: string item: T style: CSSProperties } interface Tracked { rect: DOMRect item: T } function prefersReducedMotion(): boolean { return ( typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches ) } function enterFrames(style: string): Keyframe[] { if (style === 'flip') { return [ { transform: 'perspective(700px) rotateY(-95deg)', opacity: 0, offset: 0 }, { opacity: 1, offset: 0.4 }, { transform: 'perspective(700px) rotateY(0deg)', opacity: 1, offset: 1 }, ] } // 'pop' return [ { transform: 'scale(0.4) translateY(16px)', opacity: 0 }, { transform: 'scale(1) translateY(0)', opacity: 1 }, ] } // `animate` can be turned off transiently (e.g. while a drag is in progress) to // reorder instantly without gliding; positions are still recorded so the next // animated change starts from the right place. export function useCardAnimations( items: T[], keyOf: (item: T) => string, animate = true, ): { containerRef: RefObject; ghosts: Ghost[] } { const containerRef = useRef(null) // Positions + items from the previous committed layout, keyed by card id. const prev = useRef>>(new Map()) // In-flight move animations, so a fast drag can cancel-and-replace instead of // stacking transforms on the same card. const moving = useRef>(new Map()) const [ghosts, setGhosts] = useState[]>([]) // Re-run only when membership or order actually changes — plain re-renders // (an opponent acting, connector re-measurement) leave the signature alone. const signature = items.map(keyOf).join('') useLayoutEffect(() => { const container = containerRef.current if (!container) return const itemByKey = new Map(items.map((i) => [keyOf(i), i])) const kids = container.querySelectorAll('[data-flip-key]') const measured = new Map() kids.forEach((el) => { const key = el.dataset.flipKey if (key) measured.set(key, { rect: el.getBoundingClientRect(), el, enter: el.dataset.enter ?? 'none' }) }) const commit = () => { const next = new Map>() measured.forEach((m, key) => { const item = itemByKey.get(key) if (item !== undefined) next.set(key, { rect: m.rect, item }) }) prev.current = next } if (!animate || prefersReducedMotion()) { commit() return } const before = prev.current let enterIndex = 0 // ENTER + MOVE, in DOM order (so the enter stagger runs left to right). measured.forEach((m, key) => { const was = before.get(key) if (!was) { if (m.enter !== 'none') { m.el.animate(enterFrames(m.enter), { duration: m.enter === 'flip' ? ENTER_FLIP_MS : ENTER_POP_MS, delay: Math.min(enterIndex, 8) * STAGGER_MS, easing: ENTER_EASE, fill: 'backwards', }) } enterIndex++ return } const dx = was.rect.left - m.rect.left const dy = was.rect.top - m.rect.top if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) { moving.current.get(key)?.cancel() const anim = m.el.animate( [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: MOVE_MS, easing: MOVE_EASE }, ) moving.current.set(key, anim) anim.onfinish = () => { if (moving.current.get(key) === anim) moving.current.delete(key) } } }) // EXIT: keys that were present last commit but are gone now. Pin a ghost at // the old spot (relative to the container, which is position: relative) and // let CSS shrink it away. const cRect = container.getBoundingClientRect() const newGhosts: Ghost[] = [] before.forEach((was, key) => { if (measured.has(key)) return newGhosts.push({ key, item: was.item, style: { position: 'absolute', left: was.rect.left - cRect.left + container.scrollLeft, top: was.rect.top - cRect.top + container.scrollTop, width: was.rect.width, height: was.rect.height, margin: 0, pointerEvents: 'none', }, }) }) if (newGhosts.length) { setGhosts((g) => [...g, ...newGhosts]) const gone = new Set(newGhosts.map((g) => g.key)) window.setTimeout(() => setGhosts((list) => list.filter((x) => !gone.has(x.key))), EXIT_MS + 40) } commit() // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]) return { containerRef, ghosts } }