From b48a9c21425bffdcd60b83ccff0055d5264d84ec Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 27 Sep 2026 17:10:42 +0200 Subject: [PATCH] feat(web): refine motion system and simplify animated UI (#3546) Audit every animation in the app and shared UI primitives against a frequency-first motion bar: keyboard and high-frequency actions no longer animate, remaining motion uses interruptible CSS transitions with shared easing tokens, and redundant animation code is removed. UI primitives (@reactive-resume/ui) - Dialog, alert dialog, popover and tooltip move from tw-animate keyframes to Base UI data-starting/ending-style transitions; menus, popovers and tooltips skip motion when opened from the keyboard (data-instant). - Dialog gains an `instant` prop; the command palette uses it. - Accordion animates its real panel height; caret rotates instead of swapping icons. - Menu backdrop blur moves onto the popup so it no longer snaps in after the fade; context menus and comboboxes fade only. - Sidebar collapse uses the strong ease-out curve and snaps on Cmd+B. - Toast, sheet, checkbox, tabs, toggle, inputs and message scroller get tokenised easing, correct transition properties and press feedback. - Tabs no longer squeeze a trigger narrower than its label. - Spinners keep spinning under prefers-reduced-motion. Web app - Remove the default route view transition and page-entrance slides. - Add EASE_OUT_STRONG for Motion; replace built-in "easeOut" everywhere. - Switch LazyMotion to domMax so layout and Reorder animations run. - Builder: consolidate 12 section list files into one ItemsSection, opacity-only list rows with popLayout, instant Cmd+0, faster dock zoom, crossfade that no longer dips, transform-based progress bars. - Dashboard: keep previous results while sorting/filtering, no empty-state flash, uncontrolled sidebar (no network round trip on collapse), calmer resume card tilt, no stacked hover wrappers. - Settings: drop entrance/stagger wrappers and ActionButton. - Agent: CSS marquee paused on hover; thread switches keep the layout. - Homepage: fix invalid transition declarations, CSS spotlight drift, scroll-hiding header without a JS spring, tokenised curves. - Theme switches change every color at once. - Remove SSR-only useIsClient guards from the SPA. Docs: rewrite the DESIGN.md animation section around the new tokens. --- DESIGN.md | 23 +-- .../components/animation/comet-card.test.tsx | 10 +- .../src/components/animation/comet-card.tsx | 87 ++++------- .../components/animation/count-up.test.tsx | 62 ++++---- .../web/src/components/animation/count-up.tsx | 84 ++-------- .../components/animation/spotlight.test.tsx | 24 --- .../src/components/animation/spotlight.tsx | 144 +++++------------ apps/web/src/components/input/chip-input.tsx | 86 ++++------ .../web/src/components/input/color-picker.tsx | 4 +- .../components/input/github-stars-button.tsx | 4 +- .../components/typography/font-display.tsx | 2 +- .../dialogs/resume/sections/experience.tsx | 12 +- .../src/dialogs/resume/sections/interest.tsx | 2 +- .../src/dialogs/resume/sections/profile.tsx | 2 +- .../web/src/dialogs/resume/sections/skill.tsx | 2 +- .../src/dialogs/resume/template/gallery.tsx | 4 +- .../components/application-ai-copilot.tsx | 2 +- .../components/application-card.tsx | 2 +- .../applications/components/board.tsx | 5 +- .../ats-checker/report/score-header.tsx | 8 +- .../src/features/command-palette/index.tsx | 1 + .../command-palette/pages/resumes.tsx | 2 +- .../src/features/cover-letters/library.tsx | 15 +- .../src/features/homepage/ats-playground.css | 6 - .../src/features/homepage/ats-playground.tsx | 9 +- .../features/homepage/export-playground.tsx | 6 +- .../features/homepage/feature-explorer.tsx | 6 +- .../features/homepage/languages-showcase.tsx | 2 +- apps/web/src/features/homepage/page.tsx | 6 +- .../features/homepage/resume-sculpture.tsx | 10 +- .../web/src/features/homepage/site-footer.tsx | 5 +- apps/web/src/features/homepage/styles.css | 10 -- .../src/features/homepage/template-shelf.tsx | 4 +- .../resume/builder/section-recovery.tsx | 9 +- .../resume/preview/preview.browser.tsx | 8 +- .../resume/preview/preview.shared.tsx | 5 +- .../src/features/resume/preview/preview.tsx | 4 - .../features/resume/public/public-resume.tsx | 2 +- .../resume/stylesheet/editor.test.tsx | 5 +- .../src/features/resume/stylesheet/editor.tsx | 4 +- .../components/action-button.tsx | 19 --- .../authentication/components/passkeys.tsx | 10 +- .../authentication/components/password.tsx | 45 +++--- .../components/social-provider.tsx | 52 +++---- .../authentication/components/two-factor.tsx | 45 +++--- .../settings/authentication/index.tsx | 20 +-- .../features/settings/integrations/index.tsx | 15 +- .../src/features/settings/pages/account.tsx | 47 +++--- .../src/features/settings/pages/api-keys.tsx | 60 +++---- .../features/settings/pages/preferences.tsx | 10 +- .../src/features/settings/pages/profile.tsx | 14 +- apps/web/src/features/theme/provider.tsx | 20 ++- apps/web/src/features/user/dropdown-menu.tsx | 3 - apps/web/src/index.css | 12 ++ apps/web/src/libs/motion.ts | 2 + apps/web/src/router.tsx | 1 - apps/web/src/routes/__root.tsx | 4 +- .../web/src/routes/_home/-sections/footer.tsx | 20 +-- .../web/src/routes/_home/-sections/header.tsx | 48 ++---- apps/web/src/routes/_home/ats-checker.tsx | 8 +- apps/web/src/routes/agent/$threadId.tsx | 71 +++++---- .../routes/agent/-components/agent-chat.tsx | 11 +- .../agent/-components/new-thread-setup.tsx | 4 - .../builder/$resumeId/-components/dock.tsx | 65 ++++---- .../builder/$resumeId/-components/header.tsx | 9 +- .../-components/mobile-builder-shell.tsx | 2 +- .../$resumeId/-components/preview-page.tsx | 3 + .../builder/$resumeId/-sidebar/left/index.tsx | 40 ++--- .../-sidebar/left/sections/awards.tsx | 35 ----- .../-sidebar/left/sections/certifications.tsx | 44 ------ .../-sidebar/left/sections/custom-fields.tsx | 12 +- .../-sidebar/left/sections/custom.tsx | 76 +++------ .../sections/education-experience.test.tsx | 5 +- .../-sidebar/left/sections/education.tsx | 35 ----- .../-sidebar/left/sections/experience.tsx | 44 ------ .../-sidebar/left/sections/interests.tsx | 35 ----- .../-sidebar/left/sections/languages.tsx | 35 ----- .../left/sections/many-sections.test.tsx | 15 +- .../-sidebar/left/sections/picture.tsx | 2 +- .../-sidebar/left/sections/profiles.test.tsx | 3 +- .../-sidebar/left/sections/profiles.tsx | 35 ----- .../-sidebar/left/sections/projects.test.tsx | 3 +- .../-sidebar/left/sections/projects.tsx | 40 ----- .../-sidebar/left/sections/publications.tsx | 35 ----- .../-sidebar/left/sections/references.tsx | 35 ----- .../-sidebar/left/sections/skills.test.tsx | 3 +- .../-sidebar/left/sections/skills.tsx | 35 ----- .../-sidebar/left/sections/volunteer.tsx | 41 ----- .../-sidebar/left/shared/items-section.tsx | 147 ++++++++++++++++++ .../-sidebar/left/shared/section-item.tsx | 63 ++++---- .../$resumeId/-sidebar/right/index.tsx | 3 +- .../-sidebar/right/sections/ats-check.tsx | 8 +- .../-sidebar/right/sections/design.tsx | 9 +- .../-sidebar/right/sections/layout/pages.tsx | 11 +- .../builder/$resumeId/-store/sidebar.ts | 5 +- .../src/routes/builder/$resumeId/route.tsx | 4 +- .../routes/dashboard/-components/functions.ts | 13 -- .../routes/dashboard/-components/sidebar.tsx | 25 +-- .../routes/dashboard/applications/index.tsx | 7 +- .../resumes/-components/cards/base-card.tsx | 2 +- .../resumes/-components/cards/resume-card.tsx | 20 +-- .../-components/cards/resume-thumbnail.tsx | 5 +- .../resumes/-components/grid-view.tsx | 49 +++--- .../resumes/-components/list-view.tsx | 85 +++++----- .../src/routes/dashboard/resumes/index.tsx | 13 +- apps/web/src/routes/dashboard/route.tsx | 14 +- apps/web/src/routes/templates/$.tsx | 4 +- packages/ui/src/components/accordion.test.tsx | 4 +- packages/ui/src/components/accordion.tsx | 19 +-- packages/ui/src/components/alert-dialog.tsx | 4 +- packages/ui/src/components/avatar.tsx | 5 +- packages/ui/src/components/checkbox.tsx | 4 +- packages/ui/src/components/combobox.tsx | 2 +- packages/ui/src/components/context-menu.tsx | 11 +- packages/ui/src/components/dialog.tsx | 10 +- packages/ui/src/components/dropdown-menu.tsx | 7 +- packages/ui/src/components/form.tsx | 2 +- packages/ui/src/components/input.tsx | 2 +- .../ui/src/components/message-scroller.tsx | 2 +- packages/ui/src/components/otp-field.tsx | 2 +- packages/ui/src/components/popover.tsx | 2 +- packages/ui/src/components/questionnaire.tsx | 2 +- packages/ui/src/components/sheet.tsx | 4 +- packages/ui/src/components/sidebar.tsx | 17 +- packages/ui/src/components/switch.tsx | 2 +- packages/ui/src/components/tabs.tsx | 4 +- packages/ui/src/components/textarea.tsx | 2 +- packages/ui/src/components/toast.tsx | 4 +- packages/ui/src/components/toggle.tsx | 2 +- packages/ui/src/components/tooltip.tsx | 2 +- packages/ui/src/styles/globals.css | 8 + 131 files changed, 915 insertions(+), 1611 deletions(-) delete mode 100644 apps/web/src/features/homepage/ats-playground.css delete mode 100644 apps/web/src/features/settings/authentication/components/action-button.tsx create mode 100644 apps/web/src/libs/motion.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/awards.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/certifications.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/experience.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/interests.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/languages.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/publications.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/references.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/volunteer.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/left/shared/items-section.tsx delete mode 100644 apps/web/src/routes/dashboard/-components/functions.ts 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(