Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Design engineering principles for polished UI details: concentric border radius, interruptible animations, shadows, and font smoothing.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
animations.md
1# Animations23Interruptible animations, enter/exit transitions, and contextual icon animations.45## Interruptible Animations67Users change intent mid-interaction. If animations aren't interruptible, the interface feels broken.89### CSS Transitions vs. Keyframes1011| | CSS Transitions | CSS Keyframe Animations |12| --- | --- | --- |13| **Behavior** | Interpolate toward latest state | Run on a fixed timeline |14| **Interruptible** | Yes — retargets mid-animation | No — restarts from beginning |15| **Use for** | Interactive state changes (hover, toggle, open/close) | Staged sequences that run once (enter animations, loading) |16| **Duration** | Adapts to remaining distance | Fixed regardless of state |1718```css19/* Good — interruptible transition for a toggle */20.drawer {21transform: translateX(-100%);22transition: transform 200ms ease-out;23}24.drawer.open {25transform: translateX(0);26}2728/* Clicking again mid-animation smoothly reverses — no jank */29```3031```css32/* Bad — keyframe animation for interactive element */33.drawer.open {34animation: slideIn 200ms ease-out forwards;35}3637/* Closing mid-animation snaps or restarts — feels broken */38```3940**Rule:** Always prefer CSS transitions for interactive elements. Reserve keyframes for one-shot sequences.4142## Enter Animations: Split and Stagger4344Don't animate a single large container. Break content into semantic chunks and animate each individually.4546### Step by Step47481. **Split** into logical groups (title, description, buttons)492. **Stagger** with ~100ms delay between groups503. **For titles**, consider splitting into individual words with ~80ms stagger514. **Combine** `opacity`, `blur`, and `translateY` for the enter effect5253### Code Example5455```tsx56// Motion (Framer Motion) — staggered enter57function PageHeader() {58return (59<motion.div60initial="hidden"61animate="visible"62variants={{63visible: { transition: { staggerChildren: 0.1 } },64}}65>66<motion.h167variants={{68hidden: { opacity: 0, y: 12, filter: "blur(4px)" },69visible: { opacity: 1, y: 0, filter: "blur(0px)" },70}}71>72Welcome73</motion.h1>7475<motion.p76variants={{77hidden: { opacity: 0, y: 12, filter: "blur(4px)" },78visible: { opacity: 1, y: 0, filter: "blur(0px)" },79}}80>81A description of the page.82</motion.p>8384<motion.div85variants={{86hidden: { opacity: 0, y: 12, filter: "blur(4px)" },87visible: { opacity: 1, y: 0, filter: "blur(0px)" },88}}89>90<Button>Get started</Button>91</motion.div>92</motion.div>93);94}95```9697### CSS-Only Stagger9899```css100.stagger-item {101opacity: 0;102transform: translateY(12px);103filter: blur(4px);104animation: fadeInUp 400ms ease-out forwards;105}106107.stagger-item:nth-child(1) { animation-delay: 0ms; }108.stagger-item:nth-child(2) { animation-delay: 100ms; }109.stagger-item:nth-child(3) { animation-delay: 200ms; }110111@keyframes fadeInUp {112to {113opacity: 1;114transform: translateY(0);115filter: blur(0);116}117}118```119120## Exit Animations121122Exit 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.123124### Subtle Exit (Recommended)125126```tsx127// Small fixed translateY — indicates direction without drama128<motion.div129exit={{130opacity: 0,131y: -12,132filter: "blur(4px)",133transition: { duration: 0.15, ease: "easeIn" },134}}135>136{content}137</motion.div>138```139140### Full Exit (When Context Matters)141142```tsx143// Slide fully out — use when spatial context is important144// (e.g., a card returning to a list, a drawer closing)145<motion.div146exit={{147opacity: 0,148x: "-100%",149transition: { duration: 0.2, ease: "easeIn" },150}}151>152{content}153</motion.div>154```155156### Good vs. Bad157158```css159/* Good — subtle exit */160.item-exit {161opacity: 0;162transform: translateY(-12px);163transition: opacity 150ms ease-in, transform 150ms ease-in;164}165166/* Bad — dramatic exit that steals focus */167.item-exit {168opacity: 0;169transform: translateY(-100%) scale(0.5);170transition: all 400ms ease-in;171}172173/* Bad — no exit animation at all (element just vanishes) */174.item-exit {175display: none;176}177```178179**Key points:**180- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height181- Keep some directional movement to indicate where the element went182- Exit duration should be shorter than enter duration (150ms vs 300ms)183- Don't remove exit animations entirely — subtle motion preserves context184185## Contextual Icon Animations186187When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility.188189### Motion Example190191```tsx192import { AnimatePresence, motion } from "motion/react";193194function IconButton({ isActive, icon: Icon }) {195return (196<button>197<AnimatePresence mode="popLayout">198<motion.span199key={isActive ? "active" : "inactive"}200initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}201animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}202exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}203transition={{ type: "spring", duration: 0.3, bounce: 0 }}204>205<Icon />206</motion.span>207</AnimatePresence>208</button>209);210}211```212213### CSS Transition Approach (No Motion)214215If 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.216217The 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.218219```tsx220function IconButton({ isActive, ActiveIcon, InactiveIcon }) {221return (222<button>223<div className="relative">224<div225className={cn(226"absolute inset-0 flex items-center justify-center",227"transition-[opacity,filter,scale] duration-300",228"cubic-bezier(0.2, 0, 0, 1)",229isActive230? "scale-100 opacity-100 blur-0"231: "scale-[0.25] opacity-0 blur-[4px]"232)}233>234<ActiveIcon />235</div>236<div237className={cn(238"transition-[opacity,filter,scale] duration-300",239"cubic-bezier(0.2, 0, 0, 1)",240isActive241? "scale-[0.25] opacity-0 blur-[4px]"242: "scale-100 opacity-100 blur-0"243)}244>245<InactiveIcon />246</div>247</div>248</button>249);250}251```252253The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow.254255### Choosing Between Motion and CSS256257| | Motion (Framer Motion) | CSS transitions (both icons in DOM) |258| --- | --- | --- |259| **Enter animation** | Yes | Yes |260| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) |261| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation |262| **When to use** | Project already uses `motion/react` | No motion dependency, or keeping bundle small |263264**Rule:** Check the project's `package.json` for `motion` or `framer-motion`. If present, use the Motion approach. If not, use the CSS cross-fade pattern — don't add a dependency just for icon transitions.265266### When to Animate Icons267268| Animate | Don't animate |269| --- | --- |270| Icons that appear on hover (action buttons) | Static navigation icons |271| State change icons (play → pause, like → liked) | Decorative icons |272| Icons in contextual toolbars | Icons that are always visible |273| Loading/success state indicators | Icon labels (text next to icon) |274275**Important:** Always use exactly these values for contextual icon animations — do not deviate:276- `scale`: `0.25` → `1` (never use `0.5` or `0.6`)277- `opacity`: `0` → `1`278- `filter`: `"blur(4px)"` → `"blur(0px)"`279- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value280281## Scale on Press282283A 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.284285Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting.286287### CSS Example288289```css290.button {291transition-property: scale;292transition-duration: 150ms;293transition-timing-function: ease-out;294}295296.button:active {297scale: 0.96;298}299```300301### Tailwind Example302303```tsx304<button className="transition-transform duration-150 ease-out active:scale-[0.96]">305Click me306</button>307```308309### Motion Example310311```tsx312<motion.button whileTap={{ scale: 0.96 }}>313Click me314</motion.button>315```316317### Static Prop Pattern318319Extract the scale class into a variable and conditionally apply it based on a `static` prop:320321```tsx322const tapScale = "active:not-disabled:scale-[0.96]";323324function Button({ static: isStatic, className, children, ...props }) {325return (326<button327className={cn(328"transition-transform duration-150 ease-out",329!isStatic && tapScale,330className,331)}332{...props}333>334{children}335</button>336);337}338339// Usage340<Button>Click me</Button> {/* scales on press */}341<Button static>Submit</Button> {/* no scale */}342```343344## Skip Animation on Page Load345346Use `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.347348### When It Works349350```tsx351// Good — icon doesn't animate in on mount, only on state change352<AnimatePresence initial={false} mode="popLayout">353<motion.span354key={isActive ? "active" : "inactive"}355initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}356animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}357exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}358>359<Icon />360</motion.span>361</AnimatePresence>362```363364Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load.365366### When It Breaks367368Don'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.369370```tsx371// Bad — initial={false} would skip the staggered page enter entirely372<AnimatePresence initial={false}>373<motion.div initial="hidden" animate="visible" variants={...}>374...375</motion.div>376</AnimatePresence>377```378379Verify the component still looks right on a full page refresh before applying this.380