| name | anim |
| description | 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. |
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;
}
.element:hover {
transform: translateY(-2px);
transition-duration: 0ms;
}
.element:not(:hover) {
transition-duration: 150ms;
}
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)
: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, Radix 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)
--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);
--duration-press: 120ms;
--duration-micro: 150ms;
--duration-standard: 220ms;
--duration-modal: 220ms;
--duration-drawer: 320ms;
--duration-route: 240ms;
--duration-shared: 280ms;
--stagger-tight: 45ms;
--stagger-medium: 60ms;
--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
- Radix-based surfaces use
transform-origin: var(--radix-*-content-transform-origin). 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/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: delayDuration={300}, skipDelayDuration={0} (see packages/ui/components/shadcn/tooltip.tsx). With Radix, skipDelayDuration 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 delayDuration={0} on a subtree provider (e.g. nav sidebar, rich-text toolbar) instead of inferring it from skipDelayDuration alone.
- The sidebar's
<TooltipProvider delayDuration={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