freezmo/lib/motion.ts
Martin Dvorak 374752c59e Animation pass: one shared motion language for phones
Mobile only (<=639px / coarse pointers); desktop keeps its behaviour.

Foundation
- lib/motion.ts owns the spring presets (snappy/gentle/sheet), durations,
  easings and variants (screens, tabs, wizard steps, list stagger).
- Native-feel CSS: press feedback scales every button, card, chip, tab,
  row and summary to 0.96 with a spring-like cubic-bezier on transform
  (scoped to phones/coarse pointers), tap-highlight off, touch-action
  manipulation, chrome text non-selectable, reduced motion drops it.
- App shell fades in once on launch; the study feed fades in on phones.

Applied
- Tab bar: sliding active pill (shared layoutId) + icon bounce.
- Study Decks/Homework, language toggle, difficulty and size pickers:
  the same gliding highlight, gated so desktop keeps the old styling.
- Route content enters direction-aware (deeper = from the right, back =
  from the left, same-depth = crossfade) via RouteTransition on the
  library/create/homework content columns; library tab switches
  crossfade; wizard steps slide forward/backward (reduced motion safe).
- Library: cards fade up in a capped stagger on first load, glide with
  layout animations and shrink away on delete (AnimatePresence).
- Study feed: correct feedback pulses, wrong shakes, the progress bar
  settles with a spring curve, score and XP count up on the results.
- Settings sheet uses the sheet spring; the drawer now follows the
  finger and closes on release velocity, like the sheet.
- Generation progress: the stage message fades/slides between stages;
  the icon keeps its gentle wobble; the feed fades in on hand-off.

Skipped, on purpose: LazyMotion (the sliding highlights need layout
animations = domMax, same features the current import already has);
route exit animations (the App Router unmounts the outgoing page —
exits are handled by in-page AnimatePresence); replacing the study
feed's native scroll-snap with finger-follow drag (would fight the
platform scroller); finger-follow height animation for native <details>
expanders.

Verified: animation suite 8/8 (reduced motion + 4x-CPU-throttled trace:
no long tasks, CLS 0.0000), regression 74/74, mobile 52/52, declutter
116/116, i18n 25/25. Lint + build clean. SW freezmo-v21.
2026-10-10 11:01:29 +00:00

123 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
}