Repository navigation
Animation review: dead animation plugin, missing prefers-reduced-motion, transition: all sweep #59
Description
Why
A read-only animation/motion review of packages/web/src flagged three feel-breaking regressions and several smaller craft issues. The most severe: the animate-in / fade-in-0 / zoom-in-95 / slide-in-from-* utilities used by dialog.tsx, alert-dialog.tsx, dropdown-menu.tsx, tooltip.tsx, and sheet.tsx generate zero styles because tailwindcss-animate is not registered as a plugin — modals, dropdowns, and tooltips currently open with no animation at all. On top of that, there is no prefers-reduced-motion handling anywhere in src, and transition: all (an escalation trigger) is on frequently-touched elements like Button, InputOTP, and Tabs.
Reviewed against the ten non-negotiable animation standards (justified motion, frequency-appropriate, responsive easing, sub-300ms UI, origin/physicality, interruptibility, GPU-only, accessibility, asymmetric timing, cohesion). See packages/web/.agents/skills/review-animations/STANDARDS.md for the precise curve/duration tables cited below.
Goal
Bring motion across packages/web/src up to the craft bar so that:
- Overlays (modal, dialog, dropdown, tooltip, sheet) actually animate — the v4 Tailwind setup currently ships no transform/opacity transition for these.
prefers-reduced-motionis honored repo-wide (gentler, not zero — keep opacity/color, drop movement).transition: allis replaced with scoped property lists on every frequently-touched element.- Hover timing/easing matches usage frequency (150ms color hover,
easenotease-in-out). - Hover motion is gated behind
(hover: hover) and (pointer: fine)so touch tap doesn't fire false hovers.
This is not an attempt to add new delightful motion — it's a regression-fix and cohesion pass.
Scope — findings by impact tier
Tier 1 — Feel-breaking regressions (Block)
1.1 tailwindcss-animate plugin not registered → overlays snap in with no motion
packages/web/src/styles/app.css:1-2:
@import 'tailwindcss';
@plugin "@tailwindcss/typography";The animate-in / animate-out / fade-in-0 / zoom-in-95 / slide-in-from-* / slide-out-to-* classes used in:
src/components/ui/dialog.tsx:22,39src/components/ui/alert-dialog.tsx:18,37src/components/ui/dropdown-menu.tsx:70,89(origin pattern viaorigin-[--radix-dropdown-menu-content-transform-origin]is correct ✅)src/components/ui/tooltip.tsx:21src/components/ui/sheet.tsx:24,34
silently generate nothing. Verified: grep -rn "@plugin\|@import" packages/web/src/styles/ shows only tailwindcss and @tailwindcss/typography; tailwindcss-animate is installed in node_modules but never wired in.
Fix options (pick one):
- (a)
@plugin "tailwindcss-animate";under Tailwind v4 (verify it still resolves; it's a v3 plugin). - (b) Migrate to
tw-animate-css(v4-native replacement, same class names). - (c) Hand-roll
@starting-styleblocks for the few overlays that need entry animation.
Verify: open a dialog/dropdown/tooltip in the browser, watch DevTools → Animations panel, and confirm a transform/opacity animation now fires (today it does not).
1.2 No prefers-reduced-motion handling anywhere
Zero matches in src for prefers-reduced-motion, useReducedMotion, @media (hover: hover) and (pointer: fine), or @starting-style. Every animation ignores OS preference — shake (app.css:620), caret-blink (app.css:598), slideIn (app.css:583), hover-lift (app.css:564), dropdown (app.css:858), and all the Radix overlays.
Fix:
- Add a global
@media (prefers-reduced-motion: reduce) { … }block inapp.cssthat dropstransform/slidewhile keeping opacity/color. - Add
useReducedMotion()frommotion/reactintoanimated-background.tsxmotion.divand any othermotion.*element.
1.3 transition: all on frequently-touched elements
Escalation trigger per STANDARDS (animates unintended properties, pushes some off the GPU). Sites:
| File:line | Now | Should be |
|---|---|---|
app.css:559 .auth-button |
transition-all duration-200 |
transition-[background-color,color,border-color,box-shadow] duration-200 |
components/ui/LogoShowcase.tsx:142 |
transition-all duration-1000 ease-in-out |
transition-[opacity,filter] duration-1000 cubic-bezier(0.77,0,0.175,1) |
components/ui/DiagonalPattern.tsx:22 |
transition-all ease-out (with inline filter: blur(4px)) |
transition-[opacity,filter] ease-out |
components/layout/DiagonalPatternCard.tsx:40,44 |
transition-all duration-500 ease-in-out [filter:blur(8px)]… |
transition-[opacity,filter] duration-400 ease-out |
components/ui/input-otp.tsx:42,56 |
transition-all on each OTP cell |
transition-[border-color,box-shadow,background-color,color] |
components/auth/input-otp.tsx:42 |
transition-all |
scope as above |
components/ui/tabs.tsx:32,82 |
transition-all on tab triggers |
transition-[color,background-color,box-shadow] |
components/ui/icons.tsx:6 |
transition-all duration-500 ease-in-out |
transition-colors duration-150 ease |
components/layout/typography.tsx:58 |
transition-all duration-150 |
scope to the actually-changing properties |
components/auth/auth-drawer.tsx:239 ViewContainer |
transition-all duration-300 ease-in-out with max-h-[2000px]↔max-h-0 |
animate transform, opacity instead; collapse via grid-template-rows: 1fr↔0fr or clip-path: inset(…) rather than max-height |
Tier 2 — Missed simplifications
2.1 Hover-driven magic-move indicator in animated-background.tsx
src/components/ui/animated-background.tsx:53-84 — when enableHover is set, a spring background hops to the hovered item on every onMouseEnter. Tens/day interaction = reduce. theme-toggle.tsx:84 correctly sets enableHover={false} ✅ — extend that pattern; only move the indicator on onClick/active state.
2.2 Long hover durations on icons/labels
components/ui/icons.tsx:6— 500ms is far too slow for an icon hover.components/ui/button.tsx:9— 300ms color hover is sluggish for tens-to-100+/day presses.components/ui/logo.tsx:6— 300ms color hover on the wordmark.
Pull down to 150ms.
Tier 3 — Performance
3.1 Sidebar collapse animates layout properties
src/components/ui/sidebar.tsx:
:251transition-[width] duration-200 ease-linear:261transition-[left,right,width] duration-200 ease-linear:327transition-all ease-linear(← also Tier 1.3):472transition-[margin,opacity] duration-200 ease-linear:545transition-[width,height,padding]:642transition-transform✅ (already correct)
Animating width/padding/margin/left/right triggers full layout recalc every frame. Where structurally feasible, animate via transform: translateX() on a fixed-width strip instead of changing width. At minimum replace ease-linear with cubic-bezier(0.4, 0, 0.2, 1) so the motion doesn't feel clinical.
3.2 auth-drawer.tsx ViewContainer collapses max-h-[2000px] ↔ max-h-0
src/components/auth/auth-drawer.tsx:235-251. Animating max-height janks on tall content. Prefer grid-template-rows: 1fr ↔ 0fr or clip-path: inset(…).
Tier 4 — Interruptibility & timing
4.1 Keyframes where transitions belong
app.css:583-596—.testimonial-enteruses@keyframes slideIn. Keyframes restart from zero on rapid add; transitions retarget from current state (STANDARDS, Interruptibility). Convert to a CSS transition +@starting-style.
4.2 .fade-dropdown is enter-only
app.css:858-875 — .fade-dropdown animates open but close is instant (jarring cohesion finding). Either animate both (Radix data-[state] transitions) or accept instant for both. Also uses built-in ease-out-equivalent (cubic-bezier(0.4, 0, 0.2, 1)) — keep or upgrade to cubic-bezier(0.23, 1, 0.32, 1).
4.3 Sheet uses ease-in-out for entering
src/components/ui/sheet.tsx:34:
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 …"Entering an element should start fast and settle (ease-out); ease-in-out delays the moment of reveal. Per STANDARDS, prefer iOS drawer curve cubic-bezier(0.32, 0.72, 0, 1).
Tier 5 — Origin, physicality & cohesion
5.1 Origin pattern is already correct ✅
dropdown-menu.tsx:70,89usesorigin-[--radix-dropdown-menu-content-transform-origin](scale from trigger, not center).dialog.tsxmodal uses centered transform-origin — modal exempt per STANDARDS.
No change needed; preserve this during Tier 1.1.
5.2 Hover easing rules violated repeatedly
Per STANDARDS, hover/color changes use ease. The codebase uses ease-in-out/ease-linear instead in:
components/ui/button.tsx:9(ease-in-out)components/ui/logo.tsx:6(implicitease)components/ui/link.tsx:13,72,76,102,135components/uploads/image-overlay.tsx:45,57,67(ease-in-out)components/nav/project-switcher.tsx:168,174(ease-in-out)components/ui/tabs.tsx:32,82components/auth/input-otp.tsx:42components/ui/form.tsx:97(ease-linear)routes/_login/login/index.tsx:40(ease-in-out)routes/_auth/_onboarding.tsx:76(ease-in-out)
Symptoms are subtle but accumulate as "feels slightly behind the cursor." Swap to ease (color/hover) or ease-out (entering).
5.3 DiagonalPatternCard.tsx:40,44 crosshair reveal uses ease-in-out for entering on hover
Should be ease-out — enters should snap forward, settle back.
Tier 6 — Accessibility
6.1 No (hover: hover) and (pointer: fine) gating
Ungated :hover motion leaves the element in :hover state on touch until the user taps elsewhere — false hovers appear/disappear spuriously. Sites:
components/ui/link.tsx:72,76,102(arrow nudge ongroup-hover)components/nav/project-switcher.tsx:168,174(settings icons)components/uploads/image-overlay.tsx:57,67(overlay buttons)components/ui/button.tsx:97(after:scale-xunderline)LogoShowcasehover (if applicable)
Wrap these in a hover-fine media query (or wrap in CSS @media (hover: hover) and (pointer: fine) { … } block).
Cohesion notes
- The custom CSS block at
app.css:554-642defineshover-lift,button-scale,testimonial-enter,animate-shake— initial scan found no references in*.tsx/*.ts. Likely dead CSS; confirm withgrep -rn 'hover-lift\|button-scale\|testimonial-enter\|animate-shake' packages/web/srcbefore deleting, then remove to shrink the broken-pattern surface. - Spring config in
theme-toggle.tsx:78-82({ type: 'spring', bounce: 0, duration: 0.2 }) is correct and crisp — use as the reference for any future indicator transition. ✅ auth-drawer.tsxfile-level doc comment says "fade+slide" but the implementation also includesmax-hmorphing. Either fix the comment or fix the implementation to match (Tier 3.2).
Proposed implementation waves
Ship in this order; each is independently shippable.
Wave 1 — Block fixes (highest impact, smallest diff)
- Register the animation plugin in
app.css(one line). Verify in the browser that dialog/dropdown/tooltip open withscale(0.95)+opacity:0 → scale(1)+opacity:1. Iftailwindcss-animatebreaks under v4, fall back totw-animate-cssor hand-rolled@starting-style. - Add global
@media (prefers-reduced-motion: reduce) { … }block inapp.css(drop transform/slide, keep opacity/color). - Add
useReducedMotion()guard inanimated-background.tsxmotion.div.
Wave 2 — transition: all sweep (Tier 1.3)
-
app.css:559.auth-button -
components/ui/LogoShowcase.tsx:142 -
components/ui/DiagonalPattern.tsx:22 -
components/layout/DiagonalPatternCard.tsx:40,44 -
components/ui/input-otp.tsx:42,56+components/auth/input-otp.tsx:42 -
components/ui/tabs.tsx:32,82 -
components/ui/icons.tsx:6 -
components/layout/typography.tsx:58 -
components/auth/auth-drawer.tsx:239(ViewContainer — also covers Tier 3.2)
Wave 3 — Hover timing/easing (Tier 2.2 + Tier 5.2)
-
button.tsx:9— 300ms → 150ms,ease-in-out→ease -
logo.tsx:6— 300ms → 150ms -
icons.tsx:6— 500ms → 150ms (already scoped by Wave 2) - All
link.tsxhovers,image-overlay.tsx,project-switcher.tsx,tabs.tsx,input-otp.tsx,form.tsx:97,_login/index.tsx:40,_onboarding.tsx:76— swap toease, 150ms.
Wave 4 — Interruptibility & timing (Tier 4)
-
app.css:583-596testimonial-enter— keyframes → transition +@starting-style -
app.css:858-875.fade-dropdown— add exit animation or accept instant both -
sheet.tsx:34—ease-in-out→cubic-bezier(0.32, 0.72, 0, 1), verify 250/300ms asymmetric durations
Wave 5 — Sidebar layout-property animation (Tier 3.1)
- Replace
ease-linearwithcubic-bezier(0.4, 0, 0.2, 1)on lines 251/261/327/472 - Refactor
widthanimation totransform: translateX()where structurally feasible - Fix
:327transition-all(also Wave 2)
Wave 6 — Hover gating (Tier 6)
- Wrap
group-hovertransforms in@media (hover: hover) and (pointer: fine)forlink.tsx,project-switcher.tsx,image-overlay.tsx,button.tsxafter:scale-x.
Wave 7 — Dead CSS cleanup (Cohesion notes)
- Confirm
hover-lift/button-scale/testimonial-enter/animate-shakeunused → delete fromapp.css:554-642 - Reconcile
auth-drawer.tsxdoc comment with implementation
Verification
npm run typecheck— must pass (no type regressions).npm run check:fix— Biome lint/format clean.- Manual (use
@dev-browseragent as needed):- Open a Dialog → confirm
scale(0.95)+opacity:0 → scale(1)+opacity:1over ~200ms (not instant). - Open a Dropdown → confirm scales from the trigger origin (
--radix-dropdown-menu-content-transform-origin), not center. - Hover a Button → confirm 150ms color transition with
ease(no lag). - Toggle OS "Reduce motion" → confirm overlays fade without sliding, transforms dropped but opacity/color preserved.
- Touch-tap a
PageLink(or simulate via devtools "No hovering" device) → confirm no false hover state lingers. - DevTools → Animations panel: confirm no animation fires when no plugin is registered before the fix; confirm animation fires after.
- Open a Dialog → confirm
Acceptance criteria
- Overlays (dialog, dropdown, tooltip, sheet) visibly animate open — confirmed via DevTools Animations panel.
-
prefers-reduced-motion: reducereduces alltransform/translatemotion to opacity-only acrosspackages/web/src. - No remaining
transition: allin any*.tsxcomponent undersrc/components/ui/orsrc/components/auth/. - No
ease-in-out/ease-linearon hover/color changes repo-wide; hover durations ≤ 150ms. - Hover motion gated behind
(hover: hover) and (pointer: fine). -
npm run typecheck+npm run check:fixclean.
Activity