Aur UI

Motion

Motion from Aur UI.

bash
pnpm add @aur-ui/ui
tsx
import { detailPanelVariants, emptyDetailVariants, iconSwapVariants, menuContentVariants, menuTransition, menuTransitionExit, pillSpring, sheetContentVariants, sheetOverlayTransition, sheetSpring, transitionEnter, transitionExit } from "@aur-ui/ui/lib/motion";
lib/motion.ts
/**
 * Shared motion presets — keep micro-animations consistent across the
 * dashboard. Anything beyond these primitives is too much for an admin
 * UI: list staggers and big page transitions are intentionally left out.
 */

/** ~200ms ease-out — standard fade/slide for detail panel + sheet swaps. */
export const transitionEnter = { duration: 0.2, ease: "easeOut" } as const;

/** ~140ms ease-in — exit is faster than enter so dismissals feel snappy. */
export const transitionExit = { duration: 0.14, ease: "easeIn" } as const;

/** Detail panel: subtle horizontal slide + fade. Direction matches the
 * left→right reading order: new selection slides in from the right. */
export const detailPanelVariants = {
  initial: { opacity: 0, x: 12 },
  animate: {
    opacity: 1,
    x: 0,
    transition: transitionEnter,
  },
  exit: {
    opacity: 0,
    x: -8,
    transition: transitionExit,
  },
} as const;

/** Empty-state slot uses the same fade with no horizontal travel so
 * the placeholder doesn't feel like a sibling of the real panels. */
export const emptyDetailVariants = {
  initial: { opacity: 0 },
  animate: { opacity: 1, transition: transitionEnter },
  exit: { opacity: 0, transition: transitionExit },
} as const;

/** Sheet/drawer slide. Spring matches the app-shell background push-back so the
 * two read as one coordinated motion. Slight, near-critical bounce. */
export const sheetSpring = {
  type: "spring",
  stiffness: 400,
  damping: 38,
} as const;

/** Sheet overlay scrim — a plain quick fade (tween reads cleaner than a spring
 * on opacity). */
export const sheetOverlayTransition = {
  duration: 0.2,
  ease: "easeOut",
} as const;

type SheetSide = "top" | "right" | "bottom" | "left";

/** Off-screen → in-place → off-screen slide variants for a sheet on a given
 * edge. Percentage travel keeps it measurement-free; the spring runs on the
 * transform. */
export function sheetContentVariants(side: SheetSide) {
  const offscreen =
    side === "right"
      ? { x: "100%" }
      : side === "left"
        ? { x: "-100%" }
        : side === "top"
          ? { y: "-100%" }
          : { y: "100%" };
  // Keep units consistent with `offscreen` (percent, not px) so motion
  // interpolates the slide cleanly instead of snapping across unit types.
  return {
    initial: offscreen,
    animate: { x: "0%", y: "0%" },
    exit: offscreen,
  };
}

/** ~140ms ease-out enter for menu popper content. */
export const menuTransition = { duration: 0.14, ease: "easeOut" } as const;

/** ~100ms ease-in exit for menu popper content — dismissals should feel snappier
 * than the entrance and decelerate-on-leave (ease-in), mirroring `transitionExit`. */
export const menuTransitionExit = { duration: 0.1, ease: "easeIn" } as const;

/** Menu/popover popper content: crisp scale-from-origin + fade. A tween (no
 * bounce) reads cleaner than a spring on a small dropdown; pair with
 * transform-origin = --radix-*-content-transform-origin so it grows from the
 * trigger corner and stays correct through Radix collision flips. Embeds the
 * transitions per-variant so the entrance uses ease-out and the exit ease-in;
 * pass `reduce` to collapse both to instant under reduced motion. */
export function menuContentVariants(reduce: boolean | null = false) {
  return {
    initial: { opacity: 0, scale: 0.96 },
    animate: {
      opacity: 1,
      scale: 1,
      transition: reduce ? { duration: 0 } : menuTransition,
    },
    exit: {
      opacity: 0,
      scale: 0.96,
      transition: reduce ? { duration: 0 } : menuTransitionExit,
    },
  };
}

/** Tab/segment active-pill indicator. Small-travel layout slide; slightly
 * crisper than the sheet spring. */
export const pillSpring = {
  type: "spring",
  stiffness: 500,
  damping: 40,
} as const;

/** Copy button icon swap (Copy ↔ Check). Tight scale+fade so the
 * affordance feels mechanical rather than decorative. */
export const iconSwapVariants = {
  initial: { opacity: 0, scale: 0.6 },
  animate: {
    opacity: 1,
    scale: 1,
    transition: { duration: 0.15, ease: "easeOut" },
  },
  exit: {
    opacity: 0,
    scale: 0.6,
    transition: { duration: 0.1, ease: "easeIn" },
  },
} as const;