"use client"; /** * One shared motion language for the mobile animation pass. * * Every component imports its presets from here instead of inventing ad-hoc * springs, so the whole app feels like one native surface: * * - `springSnappy` — press releases and small UI changes (180–220ms feel). * - `springGentle` — screen content, lists, feedback panels (280–350ms). * - `springSheet` — bottom sheets and the drawer, tuned for dragging. * - `EASE_OUT` — quick fades that don't need a spring. * * Only transform and opacity are ever animated (plus the explicit height * animation of expanders); blur and backdrop-filter are never touched. * `prefers-reduced-motion` is handled by the callers (CSS media query for * press feedback, `useReducedMotion` for JS springs) so motion collapses to * short fades without changing layout. */ import type { Transition, Variants } from "framer-motion"; import { useEffect, useState } from "react"; /** Press feedback and other tiny changes. ~200ms spring, no bounce overshoot. */ export const springSnappy: Transition = { type: "spring", stiffness: 520, damping: 34, mass: 0.7 }; /** Content entering/exiting a screen or list. ~300ms, a whisper of overshoot. */ export const springGentle: Transition = { type: "spring", stiffness: 300, damping: 30, mass: 0.9 }; /** Sheets and the drawer: responsive to release velocity, settles fast. */ export const springSheet: Transition = { type: "spring", stiffness: 380, damping: 36, mass: 1 }; /** Crisp fade for elements that should not move. */ export const fade: Transition = { duration: 0.18, ease: "easeOut" }; /** Plain ease for fades and progress bars. */ export const EASE_OUT = [0.22, 1, 0.36, 1] as const; /** Durations (seconds) used across the pass. */ export const DURATION = { press: 0.1, small: 0.2, screen: 0.3, sheet: 0.34, } as const; /** A screen entering from the right (drill-in) or left (back). */ export function screenVariants(direction: 1 | -1, reduceMotion: boolean): Variants { if (reduceMotion) { return { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.12 } }, exit: { opacity: 0, transition: { duration: 0.1 } }, }; } return { initial: { opacity: 0, x: 24 * direction }, animate: { opacity: 1, x: 0, transition: springGentle }, exit: { opacity: 0, x: -16 * direction, transition: { duration: 0.18, ease: "easeIn" } }, }; } /** Crossfade for same-depth route switches (tab to tab). */ export function tabVariants(reduceMotion: boolean): Variants { if (reduceMotion) { return { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }; } return { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0, transition: springGentle }, exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: "easeIn" } }, }; } /** Wizard step: forward slides in from the right, Back from the left. */ export function stepVariants(direction: 1 | -1, reduceMotion: boolean): Variants { if (reduceMotion) { return { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.12 } } }; } return { initial: { opacity: 0, x: 18 * direction }, animate: { opacity: 1, x: 0, transition: { duration: 0.24, ease: EASE_OUT } }, }; } /** Capped stagger for a list's first paint. */ export const listContainer: Variants = { hidden: {}, show: { transition: { staggerChildren: 0.045, delayChildren: 0.02 } }, }; export const listItem: Variants = { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: springGentle }, }; /** * Counts a number up over ~600ms with an ease-out curve (score, XP). Pass * `disabled` (desktop, reduced motion) to jump straight to the value. */ export function useCountUp(target: number, disabled = false): number { const [value, setValue] = useState(disabled ? target : 0); useEffect(() => { if (disabled) { setValue(target); return; } let frame = 0; const start = performance.now(); const duration = 600; const tick = (now: number) => { const progress = Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - progress, 3); setValue(Math.round(target * eased)); if (progress < 1) frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [target, disabled]); return value; }