/** * Motion tokens — single source of truth for animation timing and spring * constants across rn-motion-ui components. * * ## Usage * * Import the preset that matches the interaction, then pass it to `transition`: * ```tsx * import { MOTION_SNAPPY } from 'rn-motion-ui/theme/motion'; * * ``` * * Use `mergeTransition` when a consumer should be able to partially override the * preset without re-specifying every field: * ```tsx * const t = mergeTransition(MOTION_SNAPPY, props.pressTransition); * * ``` * * The anchored-menu section at the bottom goes one step further: `resolveMenuMotion` * returns the whole `from` / `animate` / `exit` / `transition` set that every panel * summoned by a trigger shares, so `AdaptiveDropdown`, `HoverMenu` and `Popover` * open and close identically and take the same `motion` prop. */ import { Easing } from 'react-native-reanimated'; import type { TransitionConfig } from '../moti/core/types'; // ── Re-export the canonical transition type ────────────────────────────────── export type { TransitionConfig as MotiTransitionProp } from '../moti/core/types'; // ── Duration constants (ms) ────────────────────────────────────────────────── /** 0 ms — use when `useReducedMotion()` is true. */ export const DURATION_INSTANT = 0; /** 150 ms — micro-interactions, icon swaps, small fades. */ export const DURATION_FAST = 150; /** 200 ms — default fade/slide for UI feedback (error states, backdrop). */ export const DURATION_BASE = 200; /** 300 ms — colour cross-fades, slightly heavier transitions. */ export const DURATION_SLOW = 300; /** 400 ms — deliberate morph / text cascade animations. */ export const DURATION_SLOWER = 400; // ── Shorthand timing transitions ───────────────────────────────────────────── /** `{ type: 'timing', duration: 0 }` — instant (reduced-motion fallback). */ export const TIMING_INSTANT = { type: 'timing' as const, duration: DURATION_INSTANT }; /** `{ type: 'timing', duration: 150 }` — fast fade / icon swap. */ export const TIMING_FAST = { type: 'timing' as const, duration: DURATION_FAST }; /** `{ type: 'timing', duration: 200 }` — default UI feedback. */ export const TIMING_BASE = { type: 'timing' as const, duration: DURATION_BASE }; /** `{ type: 'timing', duration: 300 }` — colour cross-fades. */ export const TIMING_SLOW = { type: 'timing' as const, duration: DURATION_SLOW }; // ── Semantic spring presets ─────────────────────────────────────────────────── /** * Snappy — fast, energetic spring for press feedback and toggles. * Equivalent to `SPRING_PRESS` in `lib/ease.ts`. */ export const MOTION_SNAPPY = { type: 'spring' as const, stiffness: 500, damping: 30, mass: 0.6, }; /** * Standard — balanced spring for most UI element transitions. * Good for tab indicators, selection pills, and small layout changes. */ export const MOTION_STANDARD = { type: 'spring' as const, stiffness: 300, damping: 24, mass: 0.8, }; /** * Gentle — soft, weighted spring for large surfaces and number tickers. * Lower stiffness keeps motion unhurried and considered. */ export const MOTION_GENTLE = { type: 'spring' as const, stiffness: 180, damping: 24, mass: 1.0, }; /** * Menu enter — a little bouncier than `MOTION_STANDARD`, so the panel settles into * place with a subtle overshoot rather than stopping dead. The damping ratio (~0.7) * is roughly a framer-motion `bounce: 0.1`. */ export const MOTION_MENU_ENTER = { type: 'spring' as const, stiffness: 350, damping: 22, mass: 0.7, }; // ── Merge helper ───────────────────────────────────────────────────────────── /** * Shallowly merges a motion preset with a consumer override. * * The consumer can change individual fields (e.g. just `stiffness`) without * re-specifying the full object. When `override` is undefined the preset is * returned as-is with no extra allocation. * * @example * const t = mergeTransition(MOTION_SNAPPY, props.pressTransition); * // t = { type: 'spring', stiffness: 500, damping: 30, mass: 0.6, ...override } */ export function mergeTransition>(preset: T, override?: Partial): T { if (!override) return preset; // biome-ignore lint/plugin: spreading Partial onto a generic T is safe — all override keys are valid TransitionConfig fields return { ...preset, ...override } as T; } // ── Anchored menu motion ───────────────────────────────────────────────────── /** * Exit for a menu panel — `{ type: 'timing', duration: 150, easing: in(cubic) }`. * * A tween rather than a spring: a panel that is leaving has nothing to settle * into, and an ease-*in* accelerates it away so the dismissal reads as decisive * where the entrance reads as arriving. */ export const MENU_EXIT_TRANSITION = { type: 'timing' as const, duration: DURATION_FAST, easing: Easing.in(Easing.cubic), }; /** Backdrop / scrim fade behind a menu — `{ type: 'timing', duration: 150 }`, both ways. */ export const MENU_SCRIM_TRANSITION = { type: 'timing' as const, duration: DURATION_FAST }; /** Scale a menu panel enters from and leaves to — visible pop-in, not a subtle settle. */ export const MENU_ENTER_SCALE = 0.85; /** Distance (px) a menu panel travels toward its trigger as it opens. */ export const MENU_ENTER_OFFSET = 12; /** Stagger delay between consecutive menu items entering, in ms. */ export const MENU_ITEM_STAGGER_MS = 25; /** Which side of the trigger a menu panel opened on. */ export type MenuSide = 'top' | 'bottom'; /** * Which edge of the trigger a menu panel is aligned to. Physical, not writing-order: * `'start'` is the left edge on every locale, matching how the anchored menus * already position themselves. */ export type MenuAlign = 'start' | 'center' | 'end'; /** CSS `transform-origin` keyword pair a menu panel scales out of. */ export type MenuTransformOrigin = `${'left' | 'center' | 'right'} ${'top' | 'bottom'}`; export type MenuOriginOptions = { side: MenuSide; align: MenuAlign }; /** * Per-menu overrides for {@link resolveMenuMotion}. Every field is optional, so a * consumer can move one number without restating the preset. * * Reduced motion wins over all of it: the panel cross-fades in place, because a * user who asked for less movement did not ask for less feedback. */ export type MenuMotion = { /** Enter spring/tween. @default MOTION_STANDARD */ enter?: Partial; /** Exit tween. @default MENU_EXIT_TRANSITION */ exit?: Partial; /** Scale the panel enters from and leaves to. `1` disables the scale. @default 0.96 */ scale?: number; /** Px the panel travels toward its trigger as it opens. `0` disables the slide. @default 8 */ offset?: number; }; /** One end of a menu panel's travel — its pose before entering, and after leaving. */ export type MenuPose = { opacity: number; scale: number; translateY: number }; export type ResolvedMenuMotion = { from: MenuPose; animate: MenuPose; exit: MenuPose; transition: TransitionConfig; exitTransition: TransitionConfig; }; export type ResolveMenuMotionOptions = { motion?: MenuMotion; /** `useReducedMotion()`. Collapses the whole thing to a fade in place. */ reduce: boolean; /** Which side of the trigger the panel landed on — decides the slide direction. */ side: MenuSide; /** * Resting `translateY`, for a panel already offset by a transform when at rest. * The slide is applied on top of it, so the panel settles here rather than at 0. * @default 0 */ restingTranslateY?: number; }; /** * The shared open/close animation for a panel anchored to a trigger. * * Spread the result onto a `MotiView` — `from` / `animate` / `exit` / * `transition` / `exitTransition` — and the panel fades and scales up out of the * corner nearest its trigger while sliding the last few px toward it. Pair it * with {@link menuTransformOrigin} so the scale grows from that corner rather * than from the middle of the panel. * * @example * const motion = resolveMenuMotion({ motion: props.motion, reduce, side: openAbove ? 'top' : 'bottom' }); * */ export function resolveMenuMotion({ motion, reduce, side, restingTranslateY = 0 }: ResolveMenuMotionOptions): ResolvedMenuMotion { const scale = reduce ? 1 : (motion?.scale ?? MENU_ENTER_SCALE); const offset = reduce ? 0 : (motion?.offset ?? MENU_ENTER_OFFSET); // Toward the trigger: a panel below it drops down into place, one above it // rises up into place. const travel = side === 'bottom' ? -offset : offset; // The same pose at both ends — a menu leaves the way it arrived, so there is // nothing to distinguish and one object serves for `from` and `exit`. const hidden: MenuPose = { opacity: 0, scale, translateY: restingTranslateY + travel }; return { from: hidden, animate: { opacity: 1, scale: 1, translateY: restingTranslateY }, exit: hidden, transition: reduce ? TIMING_FAST : mergeTransition(MOTION_MENU_ENTER, motion?.enter), exitTransition: reduce ? TIMING_FAST : mergeTransition(MENU_EXIT_TRANSITION, motion?.exit), }; } /** * `transform-origin` for a menu panel, so it scales out of the corner facing its * trigger instead of out of its own middle. * * Static style, not animated: it composes with the `scale` from * {@link resolveMenuMotion} rather than competing with it. */ export function menuTransformOrigin({ side, align }: MenuOriginOptions): MenuTransformOrigin { // A panel below its trigger grows down from its own top edge, and vice versa. const vertical = side === 'bottom' ? 'top' : 'bottom'; if (align === 'start') return `left ${vertical}`; if (align === 'end') return `right ${vertical}`; return `center ${vertical}`; }