Tasteful Web Animation — Skill
Name: anim
Purpose: Tasteful, subtle web animations following Emil Kowalski's philosophy and animations.dev principles. Use this skill when adding motion to interfaces — hover states, page transitions, micro-interactions, loading states, or any UI animation — so motion stays refined and purposeful, not decorative noise.
Applies when: Adding or reviewing UI motion (CSS, Web APIs, or React); hover and press feedback; entrances/exits; modals, toasts, menus; loading and skeleton patterns; staggered reveals; page or view transitions.
Do not use when: Motion would hurt clarity or accessibility; the task is only motion/react plumbing — pair with the motion skill for API specifics. Skip motion for validation errors, critical errors, actively read content, and high-frequency live updates.
Workflow
- Decide what the motion communicates (feedback, hierarchy, spatial continuity) — not decoration.
- Choose a duration tier: micro-interactions 150–250ms, standard transitions 200–350ms, orchestrations 400–600ms total; keep total under ~1s unless it is true loading feedback.
- Animate transform and opacity when possible; entrance ease-out, exit ease-in, exit faster than entrance; pair opacity with a small translate for entrances.
- Define reduced-motion behavior (
prefers-reduced-motion or useReducedMotion); avoid layout-affecting properties and animating on every re-render.
- Validate at 2× and 0.5× speed, check exits, and sanity-check on lower-end hardware.
Core Philosophy
Animation should be invisible. When done right, users don't notice animation — they notice that the interface feels good. The moment someone says "nice animation," you've probably overdone it.
"The best animations are the ones you don't notice." — Emil Kowalski
The 40 Rules of Tasteful Animation
Timing & Duration
Micro-interactions: 150-250ms. Hovers, button presses, toggles. Anything faster feels instant (good); anything slower feels sluggish (bad).
Standard transitions: 200-350ms. Modals opening, panels sliding, content appearing. This is your bread and butter.
Complex orchestrations: 400-600ms total. Page transitions, multi-step reveals. Never longer unless you have a very good reason.
Exit animations should be faster than entrances. Users are waiting to do something next. Enter at 300ms, exit at 200ms.
Stagger delays: 30-60ms between items. Longer staggers (100ms+) feel like a slideshow. Keep it tight.
Never animate for more than 1 second total. If your animation takes longer, it's not an animation - it's a loading screen.
Easing & Physics
Default to ease-out for entrances. Elements arriving should decelerate naturally, like a car pulling into a parking spot.
Use ease-in for exits. Elements leaving should accelerate away, like releasing a bowstring.
Use ease-in-out sparingly. Only for elements that move from point A to point B while staying on screen (dragging, repositioning).
Never use linear easing for UI. Linear is for progress bars and looping background animations only. Real objects don't move linearly.
Prefer spring physics for organic motion. Springs have natural overshoot and settle. In Motion for React, use transition={{ type: "spring", stiffness: 400, damping: 25 }} (tune as needed); in CSS, use cubic-bezier() or linear() curves that approximate a spring.
Match easing to physical metaphor. Dropping? Ease-in with bounce. Rising? Ease-out. Sliding? Ease-in-out.
Consistent easing across related elements. If a modal and its backdrop animate together, they must use the same curve.
What to Animate
Animate transform and opacity only (when possible). These are GPU-accelerated and won't cause layout thrashing.
Never animate width, height, top, left, margin, or padding. These trigger expensive layout recalculations. Use transform: scale() or translate() instead.
Animate from a definite state to a definite state. Never animate to/from auto or computed values without measuring first.
Scale from center for growth, from origin for menus. Dropdowns scale from their trigger. Modals scale from center. Be intentional.
Opacity changes should accompany movement. Don't just fade - fade AND move. opacity: 0 + translateY(8px) → opacity: 1 + translateY(0).
Keep movement distances small. 4-16px for micro-interactions. 20-40px for larger reveals. Anything more looks cartoony.
Interaction States
Hover: instant on, 150ms off. Respond immediately when hovering; ease out when leaving so it doesn't "snap" away.
Active/pressed: scale(0.97-0.98). Subtle compression. Never go below 0.95 - that's cartoon territory.
Focus: never animate the focus ring itself. Focus indicators are for accessibility. Animate the element, not the indicator.
Disabled elements: no animation. Disabled means disabled. Don't tease users with hover effects on things they can't click.
Loading states: subtle pulse or skeleton shimmer. Not spinners unless absolutely necessary. Keep the rhythm calm.
Entrance & Exit Patterns
Fade + rise for content appearing. opacity: 0, y: 8 → opacity: 1, y: 0. The classic for a reason.
Fade + sink for content disappearing. Reverse is not always best. Sometimes exit down, not up, for natural gravity.
Scale for emphasis, translate for navigation. Opening something important? Scale. Moving to a new view? Slide.
Modals: scale(0.96) + opacity, not scale(0). Starting from nothing looks cheap. Start nearly there.
Toasts: slide from edge + fade. Come from where they'll return to. Slide in from right, slide out to right.
Menus: transform-origin at trigger, scale + opacity. Dropdowns should bloom from their source.
Orchestration & Staggering
Lead with the most important element. In a stagger sequence, the primary content animates first.
Background elements animate first, foreground last. Backdrop → container → content → actions.
Use stagger for related items only. A list of cards? Stagger. Unrelated UI elements? Animate together.
Keep stagger groups small (3-7 items). More than that and the last item waits too long.
Exit in reverse order or all-at-once. Either mirror the entrance stagger (last in, first out) or don't stagger exits at all.
Performance & Accessibility
Always respect prefers-reduced-motion. Not optional. Wrap motion in @media (prefers-reduced-motion: no-preference) or check the query in JS.
Use will-change only when needed, remove after. Apply before animation starts, remove after it ends. Never leave it on permanently.
Avoid animating during scroll. Scroll-linked animations can jank. Use scroll-timeline or Intersection Observer sparingly.
Test on low-end devices. That buttery M3 Mac animation becomes a slideshow on a $200 Android.
Don't animate layout on mobile. Mobile browsers struggle with layout animations. Keep it to transforms and opacity.
CSS Implementation Patterns
Standard Transition Setup
.element {
transition:
transform 200ms ease-out,
opacity 200ms ease-out;
}
/* Hover: instant on, fade off */
.element:hover {
transform: translateY(-2px);
transition-duration: 0ms; /* instant on */
}
.element:not(:hover) {
transition-duration: 150ms; /* ease off */
}
Fade + Rise Entrance
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.entering {
animation: fadeInUp 250ms ease-out forwards;
}
Spring-like Easing (CSS)
/* Approximated spring curve */
:root {
--spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);
--spring-snappy: cubic-bezier(0.16, 1, 0.3, 1);
}
Stagger Pattern
.item {
animation: fadeInUp 200ms ease-out backwards;
}
.item:nth-child(1) {
animation-delay: 0ms;
}
.item:nth-child(2) {
animation-delay: 40ms;
}
.item:nth-child(3) {
animation-delay: 80ms;
}
.item:nth-child(4) {
animation-delay: 120ms;
}
.item:nth-child(5) {
animation-delay: 160ms;
}
Reduced Motion
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Motion (Framer Motion) Patterns
Fade + Rise
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
Spring Physics
<motion.div
animate={{ scale: 1 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
/>
Stagger Children
<motion.ul
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.04 } },
}}
>
{items.map((item) => (
<motion.li
key={item.id}
variants={{
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0 },
}}
/>
))}
</motion.ul>
Exit Before Enter (AnimatePresence)
<AnimatePresence mode="wait">
<motion.div
key={currentView}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
/>
</AnimatePresence>
Common Mistakes to Avoid
- Bouncy everything. Bounce is for celebration (confetti, success). Not for opening menus.
- Slow fades. If opacity takes more than 200ms, it feels like lag, not elegance.
- Scale(0) to scale(1). Looks like things popping into existence from nothing. Start at 0.95+.
- Inconsistent directions. If modals enter from bottom, they exit to bottom. Pick a direction and commit.
- Animating on mount unconditionally. First page load? Maybe. Every re-render? Definitely not.
- Forgetting exit animations. Things snapping away is jarring. Every entrance needs an exit strategy.
- Using animation to hide slow code. If you're animating to mask loading, fix the loading instead.
- Too many things moving at once. One focal animation, everything else is secondary or static.
When NOT to Animate
- Form validation errors (use color/icon changes instead)
- Critical error states (don't delay bad news)
- Content the user is actively reading
- High-frequency updates (live data, timers)
- Anything the user will see hundreds of times per session
Checklists
Implementation checklist
Review checklist
Testing checklist
Shared implementation (this monorepo)
The repo motion contract has two synchronized halves:
- CSS tokens in
packages/ui/styles/globals.css (:root) — the baseline for Tailwind, Base UI surfaces, and global CSS.
- TS constants in
packages/lib/motion-presets.ts — the intended pair for motion/react and shared helpers; prefer these over raw literals in new or refactored client code.
When updating one half, update the other so consumers stay in lockstep.
CSS tokens (packages/ui/styles/globals.css :root)
/* Easing — strong cubic-bezier variants, not the weak built-ins */
--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-soft: cubic-bezier(0.4, 0, 1, 1);
--ease-in-out-soft: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS curve */
/* Duration tiers — product UI stays under 300ms unless ceremonial */
--duration-press: 120ms; /* button/tile press feedback */
--duration-micro: 150ms; /* hover, color change */
--duration-standard: 220ms; /* tooltip, popover, dropdown, select */
--duration-modal: 220ms; /* dialog open/close */
--duration-drawer: 320ms; /* sheet / vaul drawer */
--duration-route: 240ms; /* matches asym-vt-route-* */
--duration-shared: 280ms; /* matches asym-vt-share-* */
/* Stagger — keep tight (30–80ms) */
--stagger-tight: 45ms;
--stagger-medium: 60ms;
/* Transform tokens — never go below 0.95 (cartoon territory) */
--scale-press: 0.98;
--scale-hover-subtle: 1.02;
--scale-entrance: 0.96;
Authoritative values live in packages/ui/styles/globals.css; keep the fenced copy above in sync when tokens change.
CSS utilities (composable, defined in the same file)
| Utility |
What it does |
Touch-safe? |
Pair with |
.press-feedback |
:active scale to var(--scale-press) |
Yes (:active fires on tap) |
Default on every <Button> already |
.hover-lift |
:hover translateY(-2px) |
Yes (@media (hover: hover) and (pointer: fine)) |
Cards, tiles |
.hover-scale-subtle |
:hover scale(var(--scale-hover-subtle)) |
Yes (@media (hover: hover) and (pointer: fine)) |
Buttons, badges, marketing CTAs |
.spinner-essential |
infinite spin exempt from the reduced-motion kill (essential status feedback) |
Yes |
Loader2 loading indicators only |
The three utilities share one transition-property declaration so you can stack them on the same element without one overriding another's transition list.
TS constants (@asym/lib/motion-presets)
EASE_OUT_SOFT, EASE_IN_SOFT, EASE_IN_OUT_SOFT, EASE_DRAWER, DURATION_PRESS, DURATION_MICRO, DURATION_STANDARD, DURATION_ROUTE, DURATION_SHARED, DURATION_DRAWER, DURATION_SLOW, STAGGER_TIGHT, STAGGER_MEDIUM, SCALE_HOVER_SUBTLE, SCALE_TAP_SUBTLE, SCALE_ENTRANCE. Semantic parity with the CSS --ease-*, --duration-* (where present), --stagger-*, and --scale-* tokens in globals.css — same numeric intent, not always the same name (e.g. DURATION_SLOW is extra-tier TS for marketing/hero; SCALE_TAP_SUBTLE matches --scale-press).
Pre-built Transition objects: transitionStandard, transitionSlow, transitionExitQuick, springTap (gestures only).
Reduced-motion-aware helpers:
propsHeroEntrance(reduceMotion, delay?, y?) — fade + small rise.
propsFadeRiseInView(reduceMotion, options?) — fade + rise on scroll-in.
propsScaleFadeInView(reduceMotion, options?) — fade + scale from SCALE_ENTRANCE.
@asym/lib/motion
Re-exports motion, AnimatePresence, LayoutGroup, useReducedMotion, plus MotionProvider (a LazyMotion features={domAnimation} wrapper). Use these in any client component that already imports from motion/react.
Repo motion standard (operative rules)
These rules are enforced by tooling/eslint-config/base.mjs (motion/react import restriction) and tests/unit/motion-contract.test.ts (banned class patterns), plus code review. Runtime contract: packages/ui/styles/globals.css and packages/lib/motion-presets.ts (update both together). Written contract (this file + docs/ai/rules/frontend.md Motion rules): how to apply them; keep all three in sync when policy changes.
When NOT to animate (in this repo)
- Anything triggered by
⌘K, ⌘B, or other keyboard shortcuts. Command palette is intentionally instant — see packages/ui/components/shadcn/command.tsx.
- Form validation errors — no extra motion; use color/icon/text for visual feedback only (errors must still be named, associated, and programmatically exposed per form a11y — not “color only” in the WCAG sense).
- Live data, timers, ticker counters —
tabular-nums and no animation.
- Sortable/filtered list reordering at high frequency — use VT shared morphs or no animation; do not add per-row springs (
packages/missionary/components/tasks/task-row.tsx is the canonical example).
- Disabled controls — no hover/press feedback (handled by
disabled:pointer-events-none disabled:opacity-50 on Button).
Hover on touch
- Any
hover:scale-*, hover:-translate-*, hover:shadow-* lift effect must be wrapped in @media (hover: hover) and (pointer: fine).
- Easiest path: use
.hover-lift or .hover-scale-subtle (both already gated). For ad-hoc group-hover: patterns where the parent owns the trigger, use the Tailwind arbitrary variant: [@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[var(--scale-hover-subtle)].
Button press
- All pressable elements get press feedback via the
Button base (which now includes .press-feedback). Native <button> elements that don't use the Button primitive should add .press-feedback directly.
- Never add
active:scale-[0.98] inline — it duplicates the contract.
Popover / tooltip / dropdown origin
- Base UI popup surfaces use
transform-origin: var(--transform-origin) (the Base UI positioner variable; origin-(--transform-origin) in Tailwind). Already correct in shared primitives — keep it.
- Modals (
Dialog, full-screen overlays) keep transform-origin: center.
Route transitions
- Owned by
RouteMainViewTransitionBoundary, applied at the app shell (e.g. donor layout.tsx, apps/missionary/components/app-shell.tsx, apps/admin/app/mc-shell.tsx — not necessarily the root layout.tsx in every app).
- The boundary's
<ViewTransition> is keyed by pathname: enter/exit can only fire on an unmount/mount pair, and a persistent layout boundary never remounts on its own. Consequence when the flag is ON (NEXT_PUBLIC_VIEW_TRANSITIONS_ENABLED=true): the route-content subtree remounts per navigation (template.tsx semantics — client state below the shell resets, route content starts scrolled to top). With the flag OFF nothing remounts and nothing animates.
- Page bodies inside the boundary get no unconditional entrance: gate CSS
animate-in classes and motion initial props with useWithinViewTransitionRouteLayer() (!withinRouteVt && "animate-in fade-in duration-300" / initial={withinRouteVt ? false : {...}} — the PageShell pattern is the template).
- Lists driven by
AnimatePresence mode="popLayout" (e.g. apps/admin/app/(app)/feed/org-updates/page-client.tsx) gate the first mount only via <AnimatePresence initial={!withinRouteVt}> — a per-item initial ternary would also kill the add/remove animation the list needs.
- The VT CSS in
globals.css must use the class selector form ::view-transition-old(.asym-vt-route-exit) (leading dot). React applies enter/exit/share prop values as view-transition-class; an undotted ident targets a view-transition name and silently matches nothing (guarded by tests/unit/motion-contract.test.ts).
- Do not add
motion.div layout on the swapping region.
CSS transitions vs motion/react tweens vs springs
- CSS transitions for all state changes (open/close, hover, press, color).
motion/react tweens for orchestrated entrances and exits (page mounts, hero strips, lists).
- Springs only for gestures and decorative interactions. Not for stat cards, list rows, or buttons.
Tooltip pattern
- Shared
TooltipProvider defaults: delay={300}, timeout={0} (see packages/ui/components/shadcn/tooltip.tsx). With Base UI, timeout is the window for skipping the open delay when moving between triggers; 0 disables that skip window — do not read it as "warm follow-up" behavior. For always-instant tooltips, set delay={0} on a subtree provider (e.g. nav sidebar, rich-text toolbar) instead of inferring it from timeout alone.
- The sidebar's
<TooltipProvider delay={0}> is a deliberate exception (collapsed-icon sidebar tooltips should be instant).
Reduced motion
- The repo-wide
prefers-reduced-motion: reduce baseline lives only in packages/ui/styles/globals.css. Apps must not redeclare it.
- New or refactored
motion/react code should use useReducedMotion() and return transition: { duration: 0 }, initial: false, or skip motion when reduced motion is on. Pattern examples: MotionPreset, RippleButton, AppIcon, task-row, task-stats, feed-post's FloatingEmoji.
- View Transitions are zeroed in CSS (
globals.css under @media (prefers-reduced-motion: reduce)).
Repo examples to copy
- Best primitive example:
packages/ui/components/shadcn/page-shell.tsx (motion + tokens + view-transition awareness in one file).
- Best button:
packages/ui/components/shadcn/button.tsx (press-feedback on the base; hover-scale-subtle for maia variants; no transition: all).
- Best card hover:
apps/admin/features/mission-control/components/tiles/tile-card.tsx (uses hover-lift only, no per-effect translate stack).
- Best list row:
packages/missionary/components/tasks/task-row.tsx (one stagger entrance, no per-row motion.layout, no per-element springs, hover lift via hover-lift).
Common mistakes / pitfalls
- Linear easing on interactive UI
- Animating layout properties on mobile or in lists
- Ignoring exit choreography
- Staggering unrelated elements or long lists
- Hover/press feedback on disabled controls
"Animation is not about moving things. It's about not making users wait." — Emil Kowalski
1---2name: anim3description: Tasteful, subtle web UI animation following Emil Kowalski / animations.dev principles. Use when adding or reviewing interface motion — hover and press feedback, entrances and exits, modals, toasts, menus, loading and skeleton states, staggered reveals, page or view transitions — so motion stays refined and purposeful, not decorative. Covers CSS, Web Animations, and React timing/easing. Pair with the motion skill for motion/react API specifics.4---56# Tasteful Web Animation — Skill78**Name:** `anim`9**Purpose:** Tasteful, subtle web animations following Emil Kowalski's philosophy and animations.dev principles. Use this skill when adding motion to interfaces — hover states, page transitions, micro-interactions, loading states, or any UI animation — so motion stays refined and purposeful, not decorative noise.1011**Applies when:** Adding or reviewing UI motion (CSS, Web APIs, or React); hover and press feedback; entrances/exits; modals, toasts, menus; loading and skeleton patterns; staggered reveals; page or view transitions.1213**Do not use when:** Motion would hurt clarity or accessibility; the task is only `motion/react` plumbing — pair with the `motion` skill for API specifics. Skip motion for validation errors, critical errors, actively read content, and high-frequency live updates.1415## Workflow16171. Decide what the motion communicates (feedback, hierarchy, spatial continuity) — not decoration.182. Choose a duration tier: micro-interactions 150–250ms, standard transitions 200–350ms, orchestrations 400–600ms total; keep total under ~1s unless it is true loading feedback.193. Animate **transform** and **opacity** when possible; entrance **ease-out**, exit **ease-in**, exit **faster** than entrance; pair opacity with a small translate for entrances.204. Define **reduced-motion** behavior (`prefers-reduced-motion` or `useReducedMotion`); avoid layout-affecting properties and animating on every re-render.215. Validate at 2× and 0.5× speed, check exits, and sanity-check on lower-end hardware.2223## Core Philosophy2425**Animation should be invisible.** When done right, users don't notice animation — they notice that the interface feels _good_. The moment someone says "nice animation," you've probably overdone it.2627> "The best animations are the ones you don't notice." — Emil Kowalski2829## The 40 Rules of Tasteful Animation3031### Timing & Duration32331. **Micro-interactions: 150-250ms.** Hovers, button presses, toggles. Anything faster feels instant (good); anything slower feels sluggish (bad).34352. **Standard transitions: 200-350ms.** Modals opening, panels sliding, content appearing. This is your bread and butter.36373. **Complex orchestrations: 400-600ms total.** Page transitions, multi-step reveals. Never longer unless you have a very good reason.38394. **Exit animations should be faster than entrances.** Users are waiting to do something next. Enter at 300ms, exit at 200ms.40415. **Stagger delays: 30-60ms between items.** Longer staggers (100ms+) feel like a slideshow. Keep it tight.42436. **Never animate for more than 1 second total.** If your animation takes longer, it's not an animation - it's a loading screen.4445### Easing & Physics46477. **Default to ease-out for entrances.** Elements arriving should decelerate naturally, like a car pulling into a parking spot.48498. **Use ease-in for exits.** Elements leaving should accelerate away, like releasing a bowstring.50519. **Use ease-in-out sparingly.** Only for elements that move from point A to point B while staying on screen (dragging, repositioning).525310. **Never use linear easing for UI.** Linear is for progress bars and looping background animations only. Real objects don't move linearly.545511. **Prefer spring physics for organic motion.** Springs have natural overshoot and settle. In Motion for React, use `transition={{ type: "spring", stiffness: 400, damping: 25 }}` (tune as needed); in CSS, use `cubic-bezier()` or `linear()` curves that approximate a spring.565712. **Match easing to physical metaphor.** Dropping? Ease-in with bounce. Rising? Ease-out. Sliding? Ease-in-out.585913. **Consistent easing across related elements.** If a modal and its backdrop animate together, they must use the same curve.6061### What to Animate626314. **Animate transform and opacity only (when possible).** These are GPU-accelerated and won't cause layout thrashing.646515. **Never animate width, height, top, left, margin, or padding.** These trigger expensive layout recalculations. Use transform: scale() or translate() instead.666716. **Animate from a definite state to a definite state.** Never animate to/from `auto` or computed values without measuring first.686917. **Scale from center for growth, from origin for menus.** Dropdowns scale from their trigger. Modals scale from center. Be intentional.707118. **Opacity changes should accompany movement.** Don't just fade - fade AND move. `opacity: 0` + `translateY(8px)` → `opacity: 1` + `translateY(0)`.727319. **Keep movement distances small.** 4-16px for micro-interactions. 20-40px for larger reveals. Anything more looks cartoony.7475### Interaction States767720. **Hover: instant on, 150ms off.** Respond immediately when hovering; ease out when leaving so it doesn't "snap" away.787921. **Active/pressed: scale(0.97-0.98).** Subtle compression. Never go below 0.95 - that's cartoon territory.808122. **Focus: never animate the focus ring itself.** Focus indicators are for accessibility. Animate the element, not the indicator.828323. **Disabled elements: no animation.** Disabled means disabled. Don't tease users with hover effects on things they can't click.848524. **Loading states: subtle pulse or skeleton shimmer.** Not spinners unless absolutely necessary. Keep the rhythm calm.8687### Entrance & Exit Patterns888925. **Fade + rise for content appearing.** `opacity: 0, y: 8` → `opacity: 1, y: 0`. The classic for a reason.909126. **Fade + sink for content disappearing.** Reverse is not always best. Sometimes exit down, not up, for natural gravity.929327. **Scale for emphasis, translate for navigation.** Opening something important? Scale. Moving to a new view? Slide.949528. **Modals: scale(0.96) + opacity, not scale(0).** Starting from nothing looks cheap. Start nearly there.969729. **Toasts: slide from edge + fade.** Come from where they'll return to. Slide in from right, slide out to right.989930. **Menus: transform-origin at trigger, scale + opacity.** Dropdowns should bloom from their source.100101### Orchestration & Staggering10210331. **Lead with the most important element.** In a stagger sequence, the primary content animates first.10410532. **Background elements animate first, foreground last.** Backdrop → container → content → actions.10610733. **Use stagger for related items only.** A list of cards? Stagger. Unrelated UI elements? Animate together.10810934. **Keep stagger groups small (3-7 items).** More than that and the last item waits too long.11011135. **Exit in reverse order or all-at-once.** Either mirror the entrance stagger (last in, first out) or don't stagger exits at all.112113### Performance & Accessibility11411536. **Always respect `prefers-reduced-motion`.** Not optional. Wrap motion in `@media (prefers-reduced-motion: no-preference)` or check the query in JS.11611737. **Use `will-change` only when needed, remove after.** Apply before animation starts, remove after it ends. Never leave it on permanently.11811938. **Avoid animating during scroll.** Scroll-linked animations can jank. Use `scroll-timeline` or Intersection Observer sparingly.12012139. **Test on low-end devices.** That buttery M3 Mac animation becomes a slideshow on a $200 Android.12212340. **Don't animate layout on mobile.** Mobile browsers struggle with layout animations. Keep it to transforms and opacity.124125## CSS Implementation Patterns126127### Standard Transition Setup128129```css130.element {131 transition:132 transform 200ms ease-out,133 opacity 200ms ease-out;134}135136/* Hover: instant on, fade off */137.element:hover {138 transform: translateY(-2px);139 transition-duration: 0ms; /* instant on */140}141.element:not(:hover) {142 transition-duration: 150ms; /* ease off */143}144```145146### Fade + Rise Entrance147148```css149@keyframes fadeInUp {150 from {151 opacity: 0;152 transform: translateY(8px);153 }154 to {155 opacity: 1;156 transform: translateY(0);157 }158}159160.entering {161 animation: fadeInUp 250ms ease-out forwards;162}163```164165### Spring-like Easing (CSS)166167```css168/* Approximated spring curve */169:root {170 --spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);171 --spring-smooth: cubic-bezier(0.22, 1, 0.36, 1);172 --spring-snappy: cubic-bezier(0.16, 1, 0.3, 1);173}174```175176### Stagger Pattern177178```css179.item {180 animation: fadeInUp 200ms ease-out backwards;181}182.item:nth-child(1) {183 animation-delay: 0ms;184}185.item:nth-child(2) {186 animation-delay: 40ms;187}188.item:nth-child(3) {189 animation-delay: 80ms;190}191.item:nth-child(4) {192 animation-delay: 120ms;193}194.item:nth-child(5) {195 animation-delay: 160ms;196}197```198199### Reduced Motion200201```css202@media (prefers-reduced-motion: reduce) {203 *,204 *::before,205 *::after {206 animation-duration: 0.01ms !important;207 animation-iteration-count: 1 !important;208 transition-duration: 0.01ms !important;209 }210}211```212213## Motion (Framer Motion) Patterns214215### Fade + Rise216217```tsx218<motion.div219 initial={{ opacity: 0, y: 8 }}220 animate={{ opacity: 1, y: 0 }}221 exit={{ opacity: 0, y: 4 }}222 transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}223/>224```225226### Spring Physics227228```tsx229<motion.div230 animate={{ scale: 1 }}231 whileTap={{ scale: 0.97 }}232 transition={{ type: "spring", stiffness: 400, damping: 25 }}233/>234```235236### Stagger Children237238```tsx239<motion.ul240 initial="hidden"241 animate="visible"242 variants={{243 visible: { transition: { staggerChildren: 0.04 } },244 }}245>246 {items.map((item) => (247 <motion.li248 key={item.id}249 variants={{250 hidden: { opacity: 0, y: 8 },251 visible: { opacity: 1, y: 0 },252 }}253 />254 ))}255</motion.ul>256```257258### Exit Before Enter (AnimatePresence)259260```tsx261<AnimatePresence mode="wait">262 <motion.div263 key={currentView}264 initial={{ opacity: 0 }}265 animate={{ opacity: 1 }}266 exit={{ opacity: 0 }}267 transition={{ duration: 0.15 }}268 />269</AnimatePresence>270```271272## Common Mistakes to Avoid273274- **Bouncy everything.** Bounce is for celebration (confetti, success). Not for opening menus.275- **Slow fades.** If opacity takes more than 200ms, it feels like lag, not elegance.276- **Scale(0) to scale(1).** Looks like things popping into existence from nothing. Start at 0.95+.277- **Inconsistent directions.** If modals enter from bottom, they exit to bottom. Pick a direction and commit.278- **Animating on mount unconditionally.** First page load? Maybe. Every re-render? Definitely not.279- **Forgetting exit animations.** Things snapping away is jarring. Every entrance needs an exit strategy.280- **Using animation to hide slow code.** If you're animating to mask loading, fix the loading instead.281- **Too many things moving at once.** One focal animation, everything else is secondary or static.282283## When NOT to Animate284285- Form validation errors (use color/icon changes instead)286- Critical error states (don't delay bad news)287- Content the user is actively reading288- High-frequency updates (live data, timers)289- Anything the user will see hundreds of times per session290291## Checklists292293### Implementation checklist294295- [ ] Durations within recommended bands; exits faster than entrances where relevant296- [ ] Primarily transform + opacity (exceptions documented)297- [ ] Reduced motion behavior defined and tested298- [ ] Stagger only for related groups; small counts (about 3–7)299300### Review checklist301302- [ ] Motion supports the task; it is not the main attraction303- [ ] No motion used to mask slow loads or errors304305### Testing checklist306307- [ ] Does it feel good at 2x speed? (If not, it's too slow)308- [ ] Does it feel good at 0.5x speed? (If not, it's too fast or lacks easing)309- [ ] Does it work with reduced motion enabled?310- [ ] Does the exit feel as considered as the entrance?311- [ ] Would a user notice if you removed it? (If yes, reconsider)312- [ ] Does it work on a $200 Android phone?313314## Shared implementation (this monorepo)315316The repo motion contract has **two synchronized halves**:3173181. **CSS tokens** in `packages/ui/styles/globals.css` (`:root`) — the baseline for Tailwind, Base UI surfaces, and global CSS.3192. **TS constants** in `packages/lib/motion-presets.ts` — the intended pair for `motion/react` and shared helpers; **prefer these over raw literals** in new or refactored client code.320321When updating one half, update the other so consumers stay in lockstep.322323### CSS tokens (`packages/ui/styles/globals.css :root`)324325```css326/* Easing — strong cubic-bezier variants, not the weak built-ins */327--ease-out-soft: cubic-bezier(0.22, 1, 0.36, 1);328--ease-in-soft: cubic-bezier(0.4, 0, 1, 1);329--ease-in-out-soft: cubic-bezier(0.77, 0, 0.175, 1);330--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS curve */331332/* Duration tiers — product UI stays under 300ms unless ceremonial */333--duration-press: 120ms; /* button/tile press feedback */334--duration-micro: 150ms; /* hover, color change */335--duration-standard: 220ms; /* tooltip, popover, dropdown, select */336--duration-modal: 220ms; /* dialog open/close */337--duration-drawer: 320ms; /* sheet / vaul drawer */338--duration-route: 240ms; /* matches asym-vt-route-* */339--duration-shared: 280ms; /* matches asym-vt-share-* */340341/* Stagger — keep tight (30–80ms) */342--stagger-tight: 45ms;343--stagger-medium: 60ms;344345/* Transform tokens — never go below 0.95 (cartoon territory) */346--scale-press: 0.98;347--scale-hover-subtle: 1.02;348--scale-entrance: 0.96;349```350351**Authoritative values** live in `packages/ui/styles/globals.css`; keep the fenced copy above in sync when tokens change.352353### CSS utilities (composable, defined in the same file)354355| Utility | What it does | Touch-safe? | Pair with |356| --------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------- |357| `.press-feedback` | `:active` scale to `var(--scale-press)` | Yes (`:active` fires on tap) | Default on every `<Button>` already |358| `.hover-lift` | `:hover` `translateY(-2px)` | Yes (`@media (hover: hover) and (pointer: fine)`) | Cards, tiles |359| `.hover-scale-subtle` | `:hover` `scale(var(--scale-hover-subtle))` | Yes (`@media (hover: hover) and (pointer: fine)`) | Buttons, badges, marketing CTAs |360| `.spinner-essential` | infinite spin exempt from the reduced-motion kill (essential status feedback) | Yes | `Loader2` loading indicators only |361362The three utilities **share** one `transition-property` declaration so you can stack them on the same element without one overriding another's transition list.363364### TS constants (`@asym/lib/motion-presets`)365366`EASE_OUT_SOFT`, `EASE_IN_SOFT`, `EASE_IN_OUT_SOFT`, `EASE_DRAWER`, `DURATION_PRESS`, `DURATION_MICRO`, `DURATION_STANDARD`, `DURATION_ROUTE`, `DURATION_SHARED`, `DURATION_DRAWER`, `DURATION_SLOW`, `STAGGER_TIGHT`, `STAGGER_MEDIUM`, `SCALE_HOVER_SUBTLE`, `SCALE_TAP_SUBTLE`, `SCALE_ENTRANCE`. **Semantic parity** with the CSS `--ease-*`, `--duration-*` (where present), `--stagger-*`, and `--scale-*` tokens in `globals.css` — same numeric intent, not always the same name (e.g. `DURATION_SLOW` is extra-tier TS for marketing/hero; `SCALE_TAP_SUBTLE` matches `--scale-press`).367368Pre-built `Transition` objects: `transitionStandard`, `transitionSlow`, `transitionExitQuick`, `springTap` (gestures only).369370Reduced-motion-aware helpers:371372- `propsHeroEntrance(reduceMotion, delay?, y?)` — fade + small rise.373- `propsFadeRiseInView(reduceMotion, options?)` — fade + rise on scroll-in.374- `propsScaleFadeInView(reduceMotion, options?)` — fade + scale from `SCALE_ENTRANCE`.375376### `@asym/lib/motion`377378Re-exports `motion`, `AnimatePresence`, `LayoutGroup`, `useReducedMotion`, plus `MotionProvider` (a `LazyMotion features={domAnimation}` wrapper). Use these in any client component that already imports from `motion/react`.379380## Repo motion standard (operative rules)381382These rules are enforced by `tooling/eslint-config/base.mjs` (motion/react import restriction) and `tests/unit/motion-contract.test.ts` (banned class patterns), plus code review. **Runtime contract:** `packages/ui/styles/globals.css` and `packages/lib/motion-presets.ts` (update both together). **Written contract (this file +** `docs/ai/rules/frontend.md` **Motion rules):** how to apply them; keep all three in sync when policy changes.383384### When NOT to animate (in this repo)385386- Anything triggered by `⌘K`, `⌘B`, or other keyboard shortcuts. Command palette is intentionally instant — see `packages/ui/components/shadcn/command.tsx`.387- Form validation errors — no extra motion; use color/icon/text for _visual_ feedback only (errors must still be named, associated, and programmatically exposed per form a11y — not “color only” in the WCAG sense).388- Live data, timers, ticker counters — `tabular-nums` and no animation.389- Sortable/filtered list reordering at high frequency — use VT shared morphs or no animation; do not add per-row springs (`packages/missionary/components/tasks/task-row.tsx` is the canonical example).390- Disabled controls — no hover/press feedback (handled by `disabled:pointer-events-none disabled:opacity-50` on `Button`).391392### Hover on touch393394- Any `hover:scale-*`, `hover:-translate-*`, `hover:shadow-*` lift effect must be wrapped in `@media (hover: hover) and (pointer: fine)`.395- Easiest path: use `.hover-lift` or `.hover-scale-subtle` (both already gated). For ad-hoc `group-hover:` patterns where the parent owns the trigger, use the Tailwind arbitrary variant: `[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-[var(--scale-hover-subtle)]`.396397### Button press398399- All pressable elements get press feedback via the `Button` base (which now includes `.press-feedback`). Native `<button>` elements that don't use the `Button` primitive should add `.press-feedback` directly.400- Never add `active:scale-[0.98]` inline — it duplicates the contract.401402### Popover / tooltip / dropdown origin403404- Base UI popup surfaces use `transform-origin: var(--transform-origin)` (the Base UI positioner variable; `origin-(--transform-origin)` in Tailwind). Already correct in shared primitives — keep it.405- Modals (`Dialog`, full-screen overlays) keep `transform-origin: center`.406407### Route transitions408409- Owned by `RouteMainViewTransitionBoundary`, applied at the **app shell** (e.g. donor `layout.tsx`, `apps/missionary/components/app-shell.tsx`, `apps/admin/app/mc-shell.tsx` — not necessarily the root `layout.tsx` in every app).410- The boundary's `<ViewTransition>` is **keyed by pathname**: enter/exit can only fire on an unmount/mount pair, and a persistent layout boundary never remounts on its own. Consequence when the flag is ON (`NEXT_PUBLIC_VIEW_TRANSITIONS_ENABLED=true`): the route-content subtree remounts per navigation (template.tsx semantics — client state below the shell resets, route content starts scrolled to top). With the flag OFF nothing remounts and nothing animates.411- Page bodies inside the boundary get **no unconditional entrance**: gate CSS `animate-in` classes and `motion` `initial` props with `useWithinViewTransitionRouteLayer()` (`!withinRouteVt && "animate-in fade-in duration-300"` / `initial={withinRouteVt ? false : {...}}` — the `PageShell` pattern is the template).412- Lists driven by `AnimatePresence mode="popLayout"` (e.g. `apps/admin/app/(app)/feed/org-updates/page-client.tsx`) gate the **first mount only** via `<AnimatePresence initial={!withinRouteVt}>` — a per-item `initial` ternary would also kill the add/remove animation the list needs.413- The VT CSS in `globals.css` must use the **class selector form** `::view-transition-old(.asym-vt-route-exit)` (leading dot). React applies `enter`/`exit`/`share` prop values as `view-transition-class`; an undotted ident targets a view-transition _name_ and silently matches nothing (guarded by `tests/unit/motion-contract.test.ts`).414- Do not add `motion.div layout` on the swapping region.415416### CSS transitions vs `motion/react` tweens vs springs417418- **CSS transitions** for all state changes (open/close, hover, press, color).419- **`motion/react` tweens** for orchestrated entrances and exits (page mounts, hero strips, lists).420- **Springs** only for gestures and decorative interactions. Not for stat cards, list rows, or buttons.421422### Tooltip pattern423424- Shared `TooltipProvider` defaults: `delay={300}`, `timeout={0}` (see `packages/ui/components/shadcn/tooltip.tsx`). With Base UI, `timeout` is the window for _skipping the open delay_ when moving between triggers; **`0` disables that skip window** — do not read it as "warm follow-up" behavior. For always-instant tooltips, set `delay={0}` on a subtree provider (e.g. nav sidebar, rich-text toolbar) instead of inferring it from `timeout` alone.425- The sidebar's `<TooltipProvider delay={0}>` is a deliberate exception (collapsed-icon sidebar tooltips should be instant).426427### Reduced motion428429- The repo-wide `prefers-reduced-motion: reduce` baseline lives **only** in `packages/ui/styles/globals.css`. Apps must not redeclare it.430- **New or refactored** `motion/react` code should use `useReducedMotion()` and return `transition: { duration: 0 }`, `initial: false`, or skip motion when reduced motion is on. Pattern examples: `MotionPreset`, `RippleButton`, `AppIcon`, `task-row`, `task-stats`, `feed-post`'s `FloatingEmoji`.431- View Transitions are zeroed in CSS (`globals.css` under `@media (prefers-reduced-motion: reduce)`).432433## Repo examples to copy434435- **Best primitive example**: `packages/ui/components/shadcn/page-shell.tsx` (motion + tokens + view-transition awareness in one file).436- **Best button**: `packages/ui/components/shadcn/button.tsx` (`press-feedback` on the base; `hover-scale-subtle` for `maia` variants; no `transition: all`).437- **Best card hover**: `apps/admin/features/mission-control/components/tiles/tile-card.tsx` (uses `hover-lift` only, no per-effect translate stack).438- **Best list row**: `packages/missionary/components/tasks/task-row.tsx` (one stagger entrance, no per-row `motion.layout`, no per-element springs, hover lift via `hover-lift`).439440## Common mistakes / pitfalls441442- Linear easing on interactive UI443- Animating layout properties on mobile or in lists444- Ignoring exit choreography445- Staggering unrelated elements or long lists446- Hover/press feedback on disabled controls447448---449450_"Animation is not about moving things. It's about not making users wait."_ — Emil Kowalski