interface FlipEntry { el: Element rect: DOMRect } let cachedReducedMotion: boolean | null = null function prefersReducedMotion(): boolean { if (cachedReducedMotion === null) { cachedReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches } return cachedReducedMotion } export function useFlipAnimation() { let firstEntries: FlipEntry[] | null = null let runningAnims: Animation[] = [] function capture(container: HTMLElement, selector: string) { cancelRunning() firstEntries = [] container.querySelectorAll(selector).forEach((el) => { firstEntries!.push({ el, rect: el.getBoundingClientRect() }) }) } function play( container: HTMLElement, selector: string, options: { duration?: number; easing?: string } = {}, ) { if (!firstEntries || prefersReducedMotion()) { firstEntries = null return } const { duration = 420, easing = 'cubic-bezier(0.16, 1, 0.3, 1)' } = options const firstByEl = new Map() for (const entry of firstEntries) { firstByEl.set(entry.el, entry.rect) } container.querySelectorAll(selector).forEach((el) => { const first = firstByEl.get(el) if (!first) return const last = el.getBoundingClientRect() const dx = first.left - last.left const dy = first.top - last.top if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return const anim = el.animate( [ { transform: `translate(${dx}px, ${dy}px)`, opacity: '1' }, { transform: 'translate(0, 0)', opacity: '1' }, ], { duration, easing, fill: 'both' }, ) runningAnims.push(anim) }) firstEntries = null } function cancelRunning() { for (const a of runningAnims) a.cancel() runningAnims = [] } return { capture, play, cancelRunning } }