"use client"; import { useReducedMotion, type Transition, type Variants } from "framer-motion"; /** * One motion vocabulary. * * There were four different stagger formulas and five entrance offsets across * the components, so nothing quite moved like anything else. More importantly, * the `prefers-reduced-motion` block in globals.css only governs CSS: Framer * animates via the CSSOM from JS, so every entrance, the aurora blobs and the * pulsing status dots all ran at full strength for people who had explicitly * asked them not to. `useMotionOK()` is the fix, and every animated component * consults it. */ export function useMotionOK() { return !useReducedMotion(); } /** Apple's motion reads as weight, not duration: overshoot slightly, settle. */ export const spring: Transition = { type: "spring", stiffness: 420, damping: 34 }; export const springSoft: Transition = { type: "spring", stiffness: 260, damping: 30 }; export const rise: Variants = { hidden: { opacity: 0, y: 8 }, shown: { opacity: 1, y: 0 }, }; export const fade: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1 }, }; /** * Capped, so item 400 of a long list does not wait eight seconds for its turn. */ export function stagger(i: number, step = 0.025, max = 0.3) { return Math.min(i * step, max); } /** * Entrance props for a list item, honouring the reduced-motion setting. * * Note what the reduced-motion branch does NOT do: return `{}`. Dropping the * props entirely removes `animate` while `initial` has already painted * opacity 0, and the element stays invisible forever. `useReducedMotion()` * also reports false on the first render and flips after mount, so that lands * exactly on the people who asked for less motion and shows them a blank page. * Instead the element is mounted already in its final state. */ export function item(i: number, ok: boolean) { if (!ok) return { initial: false as const, animate: "shown" as const, variants: rise }; return { initial: "hidden" as const, animate: "shown" as const, variants: rise, transition: { ...springSoft, delay: stagger(i) }, }; } /** * Entrance for a whole view. Deliberately translate-only, with no fade. * * This wrapper contains the entire screen, so anything that can leave it at * opacity 0 leaves the app looking broken rather than looking unanimated. A * background tab throttles rAF and freezes an in-flight animation part way — * it resumes on focus, but a stalled fade is a blank page and a stalled slide * is a few pixels of offset nobody notices. Fades stay on individual items, * where a stall is cosmetic. */ const slide: Variants = { hidden: { y: 8 }, shown: { y: 0 }, }; export function panel(ok: boolean) { if (!ok) return { initial: false as const, animate: "shown" as const, variants: slide }; return { initial: "hidden" as const, animate: "shown" as const, variants: slide, transition: { duration: 0.22, ease: [0.22, 0.9, 0.28, 1] as const }, }; }