diff --git a/DESIGN.md b/DESIGN.md index b68e244bd..74f7b3803 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -226,21 +226,24 @@ A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions ## Animation -Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern: +Motion exists to explain a change, confirm an action, or soften a jump. This is a tool people use for hours, so it stays crisp: short, precise, rarely decorative. -**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety. +**Frequency decides first.** Keyboard-initiated actions (the command palette, ⌘B sidebar toggle, zoom shortcuts, keyboard-opened menus via Base UI's `data-instant`) do not animate. Things hit tens of times a day (list rows, tooltips after the first, context menus) get opacity-only or no motion. Dialogs, sheets and toasts get a standard transition. Only rare moments (marketing pages, first load) get more. -**Timing principles:** -- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance. -- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger. -- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations. -- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements. +**Tokens.** Never hand-type a curve. +- CSS: `ease-out-strong` / `var(--ease-out-strong)` (`cubic-bezier(0.23, 1, 0.32, 1)`) for anything entering, exiting or responding; `ease-in-out-strong` for on-screen movement nobody is waiting on (ambient loops, carousels); `ease-drawer` for sheets. Never `ease-in`. +- Motion (JS): `EASE_OUT_STRONG` from `apps/web/src/libs/motion.ts`. -**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press. +**Durations.** Press feedback 100–160ms, tooltips/popovers/menus 150ms in and 100ms out, dialogs 200ms in and 150ms out, sheets 300ms. App UI stays under 300ms; marketing reveals may run 0.5–0.9s. Exits are faster than entrances. -**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s). +**Mechanics.** +- Popups use interruptible CSS transitions on Base UI's `data-starting-style` / `data-ending-style`, scale from `0.95` (never `0`) and grow from `origin-(--transform-origin)`. Modals stay centred. +- Animate `transform`/`translate`/`scale` and `opacity` only. No `transition-all`, no permanent `will-change` (Motion promotes layers while it animates). +- Presses use `active:scale-[0.97]`. `Button` already has it; don't wrap it in Motion hover/tap wrappers. +- Lists use `AnimatePresence initial={false}` so items animate when added or removed, not every time the list mounts. +- Continuous loops (marquees, drifting spotlights) are CSS keyframes, so they run off the main thread. -**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference. +**Reduced motion:** `MotionConfig reducedMotion="user"` disables Motion transforms, and CSS transitions and animations collapse to `0.01ms` — except `animate-spin`, which keeps spinning so loading never looks frozen. Values driven by `useSpring`/`useMotionValue` bypass `MotionConfig`, so check `useReducedMotion()` there. ## Elevation & Depth diff --git a/apps/web/src/components/animation/comet-card.test.tsx b/apps/web/src/components/animation/comet-card.test.tsx index 833c7716a..20ca126a2 100644 --- a/apps/web/src/components/animation/comet-card.test.tsx +++ b/apps/web/src/components/animation/comet-card.test.tsx @@ -37,20 +37,20 @@ describe("CometCard", () => { expect(glare?.className).toContain("pointer-events-none"); }); - it("does not throw when mouse enters / moves over / leaves the card", () => { + it("does not throw when pointer moves over / leaves the card", () => { const { container } = render( x , ); - const tiltable = container.querySelector("[class*='will-change-transform']") as HTMLElement; + const tiltable = container.querySelector("[class*='rounded-md']") as HTMLElement; expect(tiltable).toBeTruthy(); expect(() => { - fireEvent.mouseMove(tiltable, { clientX: 100, clientY: 50 }); - fireEvent.mouseMove(tiltable, { clientX: 0, clientY: 0 }); - fireEvent.mouseLeave(tiltable); + fireEvent.pointerMove(tiltable, { clientX: 100, clientY: 50, pointerType: "mouse" }); + fireEvent.pointerMove(tiltable, { clientX: 0, clientY: 0, pointerType: "mouse" }); + fireEvent.pointerLeave(tiltable); }).not.toThrow(); }); }); diff --git a/apps/web/src/components/animation/comet-card.tsx b/apps/web/src/components/animation/comet-card.tsx index 5a0e844c3..4b70b497c 100644 --- a/apps/web/src/components/animation/comet-card.tsx +++ b/apps/web/src/components/animation/comet-card.tsx @@ -1,63 +1,41 @@ import type React from "react"; -import { m, useMotionTemplate, useMotionValue, useSpring, useTransform } from "motion/react"; -import { useRef } from "react"; +import { m, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { cn } from "@reactive-resume/utils/style"; +import { EASE_OUT_STRONG } from "@/libs/motion"; type Props = { - rotateDepth?: number; - translateDepth?: number; glareOpacity?: number; - scaleFactor?: number; className?: string; children: React.ReactNode; }; -export const CometCard = ({ - rotateDepth = 17.5, - translateDepth = 20, - glareOpacity = 0.4, - scaleFactor = 1.05, - className, - children, -}: Props) => { - const ref = useRef(null); +// Critically damped: the tilt follows the pointer without wobbling past it. +const tiltSpring = { stiffness: 300, damping: 30 }; - const x = useMotionValue(0); - const y = useMotionValue(0); +export const CometCard = ({ glareOpacity = 0.4, className, children }: Props) => { + const reduceMotion = useReducedMotion(); - const mouseXSpring = useSpring(x); - const mouseYSpring = useSpring(y); + const x = useSpring(useMotionValue(0), tiltSpring); + const y = useSpring(useMotionValue(0), tiltSpring); - const rotateX = useTransform(mouseYSpring, [-0.5, 0.5], [`-${rotateDepth}deg`, `${rotateDepth}deg`]); - const rotateY = useTransform(mouseXSpring, [-0.5, 0.5], [`${rotateDepth}deg`, `-${rotateDepth}deg`]); - - const translateX = useTransform(mouseXSpring, [-0.5, 0.5], [`-${translateDepth}px`, `${translateDepth}px`]); - const translateY = useTransform(mouseYSpring, [-0.5, 0.5], [`${translateDepth}px`, `-${translateDepth}px`]); - - const glareX = useTransform(mouseXSpring, [-0.5, 0.5], [0, 100]); - const glareY = useTransform(mouseYSpring, [-0.5, 0.5], [0, 100]); + const rotateX = useTransform(y, [-0.5, 0.5], ["-6deg", "6deg"]); + const rotateY = useTransform(x, [-0.5, 0.5], ["6deg", "-6deg"]); + const translateX = useTransform(x, [-0.5, 0.5], ["-3px", "3px"]); + const translateY = useTransform(y, [-0.5, 0.5], ["3px", "-3px"]); + const glareX = useTransform(x, [-0.5, 0.5], [0, 100]); + const glareY = useTransform(y, [-0.5, 0.5], [0, 100]); const glareBackground = useMotionTemplate`radial-gradient(circle at ${glareX}% ${glareY}%, rgba(255, 255, 255, 0.9) 10%, rgba(255, 255, 255, 0.75) 20%, rgba(255, 255, 255, 0) 80%)`; - const handleMouseMove = (e: React.MouseEvent) => { - if (!ref.current) return; - - const rect = ref.current.getBoundingClientRect(); - - const width = rect.width; - const height = rect.height; - - const mouseX = e.clientX - rect.left; - const mouseY = e.clientY - rect.top; - - const xPct = mouseX / width - 0.5; - const yPct = mouseY / height - 0.5; - - x.set(xPct); - y.set(yPct); + // Touch taps emit synthetic pointer moves that would leave the card stuck mid-tilt, so only follow a real mouse. + const handlePointerMove = (e: React.PointerEvent) => { + if (e.pointerType !== "mouse" || reduceMotion) return; + const rect = e.currentTarget.getBoundingClientRect(); + x.set((e.clientX - rect.left) / rect.width - 0.5); + y.set((e.clientY - rect.top) / rect.height - 0.5); }; - const handleMouseLeave = () => { + const handlePointerLeave = () => { x.set(0); y.set(0); }; @@ -65,21 +43,20 @@ export const CometCard = ({ return (
{children} - + {glareOpacity > 0 && ( + + )}
); diff --git a/apps/web/src/components/animation/count-up.test.tsx b/apps/web/src/components/animation/count-up.test.tsx index e616a995d..6f3fba697 100644 --- a/apps/web/src/components/animation/count-up.test.tsx +++ b/apps/web/src/components/animation/count-up.test.tsx @@ -1,46 +1,40 @@ // @vitest-environment happy-dom -import { render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { CountUp } from "./count-up"; +import { render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { domAnimation, LazyMotion } from "motion/react"; + +const reducedMotion = vi.hoisted(() => ({ value: false })); + +vi.mock("motion/react", async (importOriginal) => ({ + ...(await importOriginal()), + useReducedMotion: () => reducedMotion.value, +})); + +const { CountUp } = await import("./count-up"); describe("CountUp", () => { - it("renders an aria-live=polite span by default (announced to screen readers)", () => { - const { container } = render(); - const span = container.querySelector("span") as HTMLSpanElement; - expect(span.getAttribute("aria-live")).toBe("polite"); - expect(span.getAttribute("aria-atomic")).toBe("true"); + it("starts at 0 before animating", () => { + reducedMotion.value = false; + const { container } = render(); + expect(container.querySelector("span")?.textContent).toBe("0"); }); - it("seeds textContent to 0 on initial render", () => { - const { container } = render(); - const span = container.querySelector("span") as HTMLSpanElement; - expect(span.textContent).toBe("0"); + it("jumps to the final grouped value under reduced motion", async () => { + reducedMotion.value = true; + const { container } = render( + + + , + ); + await waitFor(() => expect(container.querySelector("span")?.textContent).toBe("1,234")); }); - it("formats with the separator when one is supplied", () => { - const { container } = render(); - const span = container.querySelector("span") as HTMLSpanElement; - expect(span.textContent).toBe("0"); - }); - - it("preserves decimal places when to is fractional", () => { - const { container } = render(); - const span = container.querySelector("span") as HTMLSpanElement; - expect(span.textContent).toBe("0.00"); - }); - - it("strips aria-live and aria-atomic when aria-hidden is set", () => { - const { container } = render(