diff --git a/ANIMATION_GUIDE.md b/ANIMATION_GUIDE.md
new file mode 100644
index 0000000..356c36a
--- /dev/null
+++ b/ANIMATION_GUIDE.md
@@ -0,0 +1,72 @@
+# EscrowFlow Animation Guide
+
+Framer Motion, applied on top of the existing Tailwind design system — no
+parallel styling system, no duplicate components.
+
+## Spring presets (`src/lib/animations.ts`)
+
+| Preset | Use case |
+| --------- | -------------------------------------- |
+| `default` | General-purpose UI transitions |
+| `snappy` | Buttons, taps, small interactions |
+| `bouncy` | Momentum / flick interactions |
+| `smooth` | Large elements — modals, drawers |
+
+`reducedMotionTransition` (a near-instant `{ duration: 0.01 }`) is swapped in
+whenever `useReducedMotion()` reports the user prefers reduced motion.
+
+## Components
+
+### `Button` / `LinkButton` (`src/components/ui/Button.tsx`)
+Existing button API unchanged (`variant`, `size`, `disabled`, ...props).
+Now scales to 1.02 on hover / 0.98 on tap with the `snappy` spring.
+`LinkButton` wraps `next/link` via `motion.create(Link)` so the same feedback
+applies to link-styled buttons.
+
+### `Modal` (`src/components/ui/Modal.tsx`)
+`isOpen`, `onClose`, `title`, `children`. Backdrop fades in/out; panel enters
+with a `smooth` spring (slide + fade), fully interruptible via
+`AnimatePresence`.
+
+### `Toast` (`src/components/ui/Toast.tsx`)
+`message`, `type` (`success` | `error` | `warning` | `info`), `isVisible`,
+`onClose`, optional `duration` (defaults to 3000ms). Auto-dismisses via a
+`useEffect` timer keyed on `isVisible` — not on animation completion, so it
+won't re-fire on exit.
+
+### `Skeleton` (`src/components/ui/Skeleton.tsx`)
+`className` for sizing. Shimmering opacity pulse; static when reduced motion
+is preferred. Used for the dashboard's loading state.
+
+## Typography (`tailwind.config.ts`)
+Added `text-display-lg`, `text-display-md`, `text-heading-lg/md/sm`,
+`text-body-lg`, `text-label-caps`, `text-caption` to the existing Tailwind
+`fontSize` scale. `text-base` / `text-sm` were left as-is since they already
+matched the intended body sizes — no need to duplicate them.
+
+## Reduced motion (`src/hooks/useReducedMotion.ts`)
+Tracks `(prefers-reduced-motion: reduce)` live via `matchMedia`. Every
+animated component checks it and either skips the animation or uses
+`reducedMotionTransition`.
+
+## Dashboard (`src/app/app/page.tsx`)
+Loading state now uses the shared `Skeleton`. The projects grid and recent
+payments list stagger in with the `default` spring (0.06s stagger), skipped
+entirely under reduced motion.
+
+## Testing
+1. Hover/tap a button — should feel snappy, no lag.
+2. Open/close a `Modal` — slide + fade, interruptible mid-animation.
+3. Trigger a `Toast` — enters top-left-ish offset, auto-dismisses once
+ after ~3s, no repeat-fire on close.
+4. Enable OS-level reduced motion — all of the above should become
+ near-instant with no scale/slide/stagger.
+5. Keyboard: buttons and the modal close button remain focusable and
+ operable via keyboard.
+
+## Notes
+- `window.matchMedia` is polyfilled in `src/test/setup.ts` since jsdom
+ doesn't implement it — needed once any component uses
+ `useReducedMotion`.
+- `Button.tsx` and `useReducedMotion.ts` are marked `"use client"` since
+ they're pulled in by Server Components (e.g. the landing page hero).
diff --git a/package-lock.json b/package-lock.json
index 9534b9b..a101ce0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@stellar/stellar-sdk": "^16.2.0",
+ "framer-motion": "^13.1.0",
"jose": "^6.2.8",
"lucide-react": "^0.427.0",
"next": "^14.2.5",
@@ -4845,6 +4846,29 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.0.tgz",
+ "integrity": "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^13.0.0",
+ "motion-utils": "^13.0.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -6294,6 +6318,21 @@
"node": ">=16 || 14 >=14.17"
}
},
+ "node_modules/motion-dom": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.0.0.tgz",
+ "integrity": "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^13.0.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
+ "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/package.json b/package.json
index b89d51b..23b2837 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
},
"dependencies": {
"@stellar/stellar-sdk": "^16.2.0",
+ "framer-motion": "^13.1.0",
"jose": "^6.2.8",
"lucide-react": "^0.427.0",
"next": "^14.2.5",
diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx
index 44e3f72..211ec11 100644
--- a/src/app/app/page.tsx
+++ b/src/app/app/page.tsx
@@ -1,24 +1,35 @@
"use client";
import { useEffect, useState } from "react";
+import { motion } from "framer-motion";
import { Download, FolderKanban, Plus, Upload } from "lucide-react";
import { BalanceCard } from "@/components/dashboard/BalanceCard";
import { ProjectListItem } from "@/components/dashboard/ProjectListItem";
import { PaymentListItem } from "@/components/dashboard/PaymentListItem";
import { EmptyState } from "@/components/ui/EmptyState";
import { LinkButton } from "@/components/ui/Button";
+import { Skeleton } from "@/components/ui/Skeleton";
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+import { springs } from "@/lib/animations";
import { projectsForRole, useAppStore } from "@/lib/store";
import { useAuthStore } from "@/store/auth.store";
import { computeWalletSummary, paymentDirection } from "@/lib/mock/service";
-function SkeletonBlock({ className }: { className?: string }) {
- return
;
-}
+const LIST_CONTAINER = {
+ hidden: {},
+ show: { transition: { staggerChildren: 0.06 } },
+};
+
+const LIST_ITEM = {
+ hidden: { opacity: 0, y: 12 },
+ show: { opacity: 1, y: 0 },
+};
export default function OverviewPage() {
const [isLoading, setIsLoading] = useState(true);
const state = useAppStore((s) => s.state);
const userRole = useAuthStore((s) => s.userRole);
+ const prefersReducedMotion = useReducedMotion();
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 350);
@@ -34,9 +45,9 @@ export default function OverviewPage() {
if (isLoading) {
return (
-
-
-
+
+
+
);
}
@@ -93,11 +104,18 @@ export default function OverviewPage() {
}
/>
) : (
-
+
{projects.map((project) => (
-
+
+
+
))}
-
+
)}
@@ -110,11 +128,18 @@ export default function OverviewPage() {
description="Payments from milestone releases, deposits, and withdrawals will appear here."
/>
) : (
-
+
{recentPayments.map((payment) => (
-
+
+
+
))}
-
+
)}
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 34418cb..d113066 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -1,6 +1,12 @@
+"use client";
+
import { cn } from "@/lib/utils";
+import { reducedMotionTransition, springs } from "@/lib/animations";
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+import { motion, type HTMLMotionProps } from "framer-motion";
import Link from "next/link";
-import type { ButtonHTMLAttributes } from "react";
+
+const MotionLink = motion.create(Link);
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md" | "lg";
@@ -21,15 +27,21 @@ const SIZE_CLASSES: Record = {
const BASE =
"inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-colors disabled:opacity-50 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2";
-interface ButtonProps extends ButtonHTMLAttributes {
+interface ButtonProps extends HTMLMotionProps<"button"> {
variant?: Variant;
size?: Size;
}
-export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
+export function Button({ variant = "primary", size = "md", className, disabled, ...props }: ButtonProps) {
+ const prefersReducedMotion = useReducedMotion();
+
return (
-
);
@@ -44,9 +56,17 @@ interface LinkButtonProps {
}
export function LinkButton({ href, variant = "primary", size = "md", className, children }: LinkButtonProps) {
+ const prefersReducedMotion = useReducedMotion();
+
return (
-
+
{children}
-
+
);
}
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..187e1cf
--- /dev/null
+++ b/src/components/ui/Modal.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { X } from "lucide-react";
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+import { reducedMotionTransition, springs } from "@/lib/animations";
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+}
+
+export function Modal({ isOpen, onClose, title, children }: ModalProps) {
+ const prefersReducedMotion = useReducedMotion();
+ const panelTransition = prefersReducedMotion ? reducedMotionTransition : springs.smooth;
+
+ return (
+
+ {isOpen && (
+
+
+
+
+
+
{title}
+
+
+ {children}
+
+
+ )}
+
+ );
+}
diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..07e792f
--- /dev/null
+++ b/src/components/ui/Skeleton.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+import { cn } from "@/lib/utils";
+
+interface SkeletonProps {
+ className?: string;
+}
+
+export function Skeleton({ className }: SkeletonProps) {
+ const prefersReducedMotion = useReducedMotion();
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx
new file mode 100644
index 0000000..8b598af
--- /dev/null
+++ b/src/components/ui/Toast.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useEffect } from "react";
+import { AnimatePresence, motion } from "framer-motion";
+import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+import { reducedMotionTransition, springs } from "@/lib/animations";
+import { cn } from "@/lib/utils";
+
+export type ToastType = "success" | "error" | "warning" | "info";
+
+interface ToastProps {
+ message: string;
+ type: ToastType;
+ isVisible: boolean;
+ onClose: () => void;
+ /** Milliseconds before auto-dismissing. Defaults to 3000. */
+ duration?: number;
+}
+
+const ICONS: Record = {
+ success: CheckCircle2,
+ error: XCircle,
+ warning: AlertTriangle,
+ info: Info,
+};
+
+const TYPE_CLASSES: Record = {
+ success: "bg-success text-white",
+ error: "bg-danger text-white",
+ warning: "bg-warning text-white",
+ info: "bg-primary text-white",
+};
+
+export function Toast({ message, type, isVisible, onClose, duration = 3000 }: ToastProps) {
+ const prefersReducedMotion = useReducedMotion();
+ const Icon = ICONS[type];
+
+ useEffect(() => {
+ if (!isVisible) return;
+ const timer = setTimeout(onClose, duration);
+ return () => clearTimeout(timer);
+ }, [isVisible, duration, onClose]);
+
+ return (
+
+ {isVisible && (
+
+
+ {message}
+
+ )}
+
+ );
+}
diff --git a/src/hooks/useReducedMotion.ts b/src/hooks/useReducedMotion.ts
new file mode 100644
index 0000000..14a482b
--- /dev/null
+++ b/src/hooks/useReducedMotion.ts
@@ -0,0 +1,21 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+const QUERY = "(prefers-reduced-motion: reduce)";
+
+/** Tracks the user's OS-level reduced-motion preference. */
+export function useReducedMotion(): boolean {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
+
+ useEffect(() => {
+ const mediaQuery = window.matchMedia(QUERY);
+ setPrefersReducedMotion(mediaQuery.matches);
+
+ const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches);
+ mediaQuery.addEventListener("change", handler);
+ return () => mediaQuery.removeEventListener("change", handler);
+ }, []);
+
+ return prefersReducedMotion;
+}
diff --git a/src/lib/animations.ts b/src/lib/animations.ts
new file mode 100644
index 0000000..9719199
--- /dev/null
+++ b/src/lib/animations.ts
@@ -0,0 +1,39 @@
+import type { Transition } from "framer-motion";
+
+/** Spring presets matching Apple's HIG motion values. */
+export const springs = {
+ /** Smooth, no bounce — default UI transitions. */
+ default: {
+ type: "spring",
+ damping: 10,
+ stiffness: 100,
+ mass: 1,
+ },
+
+ /** Quick & snappy — buttons, small interactions. */
+ snappy: {
+ type: "spring",
+ damping: 12,
+ stiffness: 200,
+ mass: 1,
+ },
+
+ /** Bouncy, playful — momentum/flick interactions. */
+ bouncy: {
+ type: "spring",
+ damping: 8,
+ stiffness: 100,
+ mass: 1,
+ },
+
+ /** Slow & smooth — large elements, modals, drawers. */
+ smooth: {
+ type: "spring",
+ damping: 15,
+ stiffness: 60,
+ mass: 1,
+ },
+} satisfies Record;
+
+/** Near-instant transition used when the user prefers reduced motion. */
+export const reducedMotionTransition: Transition = { duration: 0.01 };
diff --git a/src/test/setup.ts b/src/test/setup.ts
index f149f27..510d260 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1,14 @@
import "@testing-library/jest-dom/vitest";
+
+if (typeof window !== "undefined" && !window.matchMedia) {
+ window.matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {},
+ removeListener: () => {},
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ dispatchEvent: () => false,
+ }) as unknown as MediaQueryList;
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 4fa096e..1e5fd7c 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -33,6 +33,16 @@ const config: Config = {
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
},
+ fontSize: {
+ "display-lg": ["clamp(2rem, 5vw, 3rem)", { lineHeight: "1.2", letterSpacing: "-0.02em", fontWeight: "700" }],
+ "display-md": ["clamp(1.5rem, 4vw, 2rem)", { lineHeight: "1.3", letterSpacing: "-0.01em", fontWeight: "600" }],
+ "heading-lg": ["1.5rem", { lineHeight: "1.4", fontWeight: "600" }],
+ "heading-md": ["1.25rem", { lineHeight: "1.4", fontWeight: "600" }],
+ "heading-sm": ["1rem", { lineHeight: "1.5", fontWeight: "600" }],
+ "body-lg": ["1.125rem", { lineHeight: "1.6", fontWeight: "400" }],
+ "label-caps": ["0.75rem", { lineHeight: "1.3", letterSpacing: "0.08em", fontWeight: "600" }],
+ caption: ["0.75rem", { lineHeight: "1.4", fontWeight: "400" }],
+ },
borderRadius: {
xl: "0.875rem",
"2xl": "1.25rem",