# Animations Interruptible animations, enter/exit transitions, contextual icon animations, and motion restraint. ## Interruptible Animations Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken. ### CSS Transitions vs. Keyframes | | CSS Transitions | CSS Keyframe Animations | | --- | --- | --- | | **Behavior** | Interpolate toward latest state | Run on a fixed timeline | | **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning | | **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) | | **Duration** | Fixed; retargets the value mid-flight, not the timeline | Fixed timeline, restarts from the beginning | ```css /* Good — interruptible transition for a toggle */ .drawer { transform: translateX(-100%); transition: transform 200ms ease-out; } .drawer.open { transform: translateX(0); } /* Clicking again mid-animation smoothly reverses — no jank */ ``` ```css /* Bad — keyframe animation for interactive element */ .drawer.open { animation: slideIn 200ms ease-out forwards; } /* Closing mid-animation snaps or restarts — feels broken */ ``` **Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences. ## Enter Animations: Split and Stagger Use this pattern for infrequent staged entrances where sequence helps communicate hierarchy, such as the first load of a page hero, success state, or empty state. Break a large container into semantic chunks and animate each individually. Do not stagger routine interactions such as row hovers, keystrokes, or repeated tab changes. ### Step by Step 1. **Split** into logical groups (title, description, buttons) 2. **Stagger** with ~100ms delay between groups 3. **For titles**, consider splitting into individual words with ~80ms stagger 4. **Combine** `opacity`, `blur`, and `translateY` for the enter effect ### Code Example ```tsx // Motion (Framer Motion) — staggered enter function PageHeader() { return ( Welcome A description of the page. ); } ``` ### CSS-Only Stagger ```css .stagger-item { opacity: 0; transform: translateY(12px); filter: blur(4px); animation: fadeInUp 400ms ease-out forwards; } .stagger-item:nth-child(1) { animation-delay: 0ms; } .stagger-item:nth-child(2) { animation-delay: 100ms; } .stagger-item:nth-child(3) { animation-delay: 200ms; } @keyframes fadeInUp { to { opacity: 1; transform: translateY(0); filter: blur(0); } } ``` ## Exit Animations Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention. ### Subtle Exit (Recommended) ```tsx // Small fixed translateY — indicates direction without drama {content} ``` ### Full Exit (When Context Matters) ```tsx // Slide fully out — use when spatial context is important // (e.g., a card returning to a list, a drawer closing) {content} ``` ### Good vs. Bad ```css /* Good — subtle exit */ .item-exit { opacity: 0; transform: translateY(-12px); transition: opacity 150ms ease-out, transform 150ms ease-out; } /* Bad — dramatic exit that steals focus */ .item-exit { opacity: 0; transform: translateY(-100%) scale(0.5); transition: all 400ms ease-out; } /* Sometimes correct — remove immediately when motion adds no context */ .item-exit { display: none; } ``` **Key points:** - Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height - Keep some directional movement to indicate where the element went - Exit duration should be shorter than enter duration (150ms vs 300ms) - Use a subtle exit when it preserves spatial context. Remove immediately when motion adds no information, the interaction repeats frequently, or reduced motion is requested. ## Contextual Icon Animations When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility. ### Motion Example This example uses the `motion` package. If the project instead has `framer-motion`, import the same APIs from `"framer-motion"`; never mix an installed package with the other package's import path. ```tsx import { AnimatePresence, motion } from "motion/react"; function IconButton({ isActive, icon: Icon }) { return ( ); } ``` ### CSS Transition Approach (No Motion) If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly. The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur. ```tsx function IconButton({ isActive, ActiveIcon, InactiveIcon }) { return ( ); } ``` The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow. ### Choosing Between Motion and CSS | | Motion (Framer Motion) | CSS transitions (both icons in DOM) | | --- | --- | --- | | **Enter animation** | Yes | Yes | | **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) | | **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation | | **When to use** | Project already uses `motion` or `framer-motion` | No motion dependency, or keeping bundle small | **Rule:** Check the project's `package.json`. Import from `"motion/react"` when `motion` is installed, or from `"framer-motion"` when `framer-motion` is installed. If both exist, follow the imports already used by the component or its nearest peers. If neither is present, use the CSS cross-fade pattern — don't add a dependency just for icon transitions. ### When to Animate Icons | Animate | Don't animate | | --- | --- | | Icons that appear on hover (action buttons) | Static navigation icons | | State change icons (play → pause, like → liked) | Decorative icons | | Icons in contextual toolbars | Icons that are always visible | | Loading/success state indicators | Icon labels (text next to icon) | **Important:** Always use exactly these values for contextual icon animations — do not deviate: - `scale`: `0.25` → `1` (never use `0.5` or `0.6`) - `opacity`: `0` → `1` - `filter`: `"blur(4px)"` → `"blur(0px)"` - `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value ## Scale on Press A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return. Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting. ### CSS Example ```css .button { transition-property: scale; transition-duration: 150ms; transition-timing-function: ease-out; } .button:active { scale: 0.96; } ``` ### Tailwind Example ```tsx ``` ### Motion Example ```tsx Click me ``` ### Static Prop Pattern Extract the scale class into a variable and conditionally apply it based on a `static` prop: ```tsx const tapScale = "active:not-disabled:scale-[0.96]"; function Button({ static: isStatic, className, children, ...props }) { return ( ); } // Usage {/* scales on press */} {/* no scale */} ``` ## Skip Animation on Page Load Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes. ### When It Works ```tsx // Good — icon doesn't animate in on mount, only on state change ``` Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load. ### When It Breaks Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance. ```tsx // Bad — initial={false} would skip the staggered page enter entirely ... ``` Verify the component still looks right on a full page refresh before applying this. ## Motion Restraint Motion is a budget, not a garnish: - **No custom animation on high-frequency interactions.** Repeated interactions get instant feedback or a minimal `opacity` or `background-color` transition at ≤150ms. - **Motion is never the only feedback channel.** Every animated state change also needs a static cue such as color, icon, or label. - **Brief and precise beats prominent.** If a shorter, smaller animation communicates the same thing, use it. - **Honor reduced-motion preferences.** Preserve the static cue and remove unnecessary movement. ```css /* Good: high-frequency hover gets a minimal transition */ .row:hover { background-color: var(--surface-hover); transition: background-color 100ms ease-out; } /* Bad: every hover replays a full entrance */ .row:hover .row-icon { animation: bounceIn 500ms; } ```