
Ui Animation
- 7.6k installs
- 72 repo stars
- Updated July 29, 2026
- mblode/agent-skills
ui-animation is an agent skill for designing implementing and reviewing UI motion with CSS transitions springs gestures clip-path techniques and strict animation review standards.
About
ui-animation is an agent skill for creating reviewing and debugging UI motion across springs gestures drag clip-path reveals easing CSS transitions keyframes and framer-motion. Core rules animate only transform and opacity for movement, forbid layout property animation and transition all, and never animate keyboard-initiated actions. Implementation priority is CSS transitions over WAAPI keyframes and JS requestAnimationFrame with asymmetric enter and exit timing tuned by interaction frequency. Motion principles demand continuity over teleportation, directional motion matching layout, overlays emerging from triggers, and delight scaling inversely with frequency. Reference files cover decision framework spring animations component patterns clip-path gesture drag performance review format contextual animations and transition recipes for badges dropdowns modals and page slides. Accessibility requires prefers-reduced-motion paths hover gated behind fine pointer media queries and paired elements sharing duration and easing. Review workflow applies ten standards with Before After Why tables and explicit Block or Approve verdicts.
- Animate transform and opacity only; never width height top left or transition all.
- CSS transitions preferred over keyframes for interruptible UI that retargets cleanly.
- Transition recipes mapped to badges dropdowns modals panels page slides and icon swaps.
- prefers-reduced-motion and hover hover fine pointer guards on all recipes.
- Strict review format with ten standards and Block or Approve verdict output.
Ui Animation by the numbers
- 7,606 all-time installs (skills.sh)
- +229 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #60 of 1,888 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
ui-animation capabilities & compatibility
- Capabilities
- decision framework for whether and why to animat · css transition recipes for modals dropdowns badg · spring gesture drag and clip path technique refe · performance deep dive for waapi compositing and · strict animation review with block approve verdi · prefers reduced motion and hover pointer fine ac
- Use cases
- frontend · ui design · web design
What ui-animation says it does
Animate for feedback, orientation, continuity, or deliberate delight.
Never animate keyboard-initiated actions (shortcuts, arrow navigation, tab/focus); they repeat hundreds of times daily and animation makes them feel slow.
Implementation priority: CSS transitions > WAAPI > CSS keyframes > JS (`requestAnimationFrame`).
npx skills add https://github.com/mblode/agent-skills --skill ui-animationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7.6k |
|---|---|
| repo stars | ★ 72 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 29, 2026 |
| Repository | mblode/agent-skills ↗ |
How do I add smooth purposeful UI animations without jank layout thrash or accessibility failures?
Design, implement, and review UI motion including CSS transitions, springs, gestures, clip-path reveals, and animation code reviews.
Who is it for?
Frontend work on transitions modals drawers drag gestures spring physics and animation code review.
Skip if: Extracting motion from screen recordings use reverse-engineer-animation, overall visual direction use ui-design, or full page audits use ui-audit.
When should I use this skill?
User asks to add animations, review motion code, implement swipe gestures, pick easing, or make interactions feel smooth.
What you get
Motion implementations or reviews with correct easing durations reduced-motion paths and evidence-backed Block or Approve decisions.
- clip-path CSS recipes
- micro-interaction patterns
- scroll reveal snippets
Files
UI Animation
- IS: designing, implementing, reviewing, and debugging UI motion: springs, gestures, drag, easing, CSS transitions, keyframes, framer-motion.
- IS NOT: extracting an animation from a video or screen recording (use
reverse-engineer-animation), choosing overall visual direction, palettes, or typography (useui-design), or auditing a whole page's UI quality (useui-audit).
Reference files
| File | Read when |
|---|---|
| references/decision-framework.md | Default: deciding whether/why to animate, picking easing character |
| references/spring-animations.md | Using spring physics, framer-motion useSpring, configuring spring params |
| references/component-patterns.md | Building buttons, popovers, tooltips, drawers, modals, toasts with animation |
| references/clip-path-techniques.md | Using clip-path for reveals, tabs, hold-to-delete, comparison sliders |
| references/gesture-drag.md | Implementing drag, swipe-to-dismiss, momentum, pointer capture |
| references/performance-deep-dive.md | Debugging jank, CSS vs JS, WAAPI, CSS variables trap, Framer Motion caveats |
| references/review-format.md | Reviewing animation code: strict review with ten standards, escalation triggers, Before/After/Why table, and a Block/Approve verdict |
| references/contextual-animations.md | Implementing contextual icon swaps, word-level stagger entrances, or fixed-offset exit animations |
| references/transition-recipes.md | Installing a CSS transition: card resize, badge, dropdown, modal, panel, page slide, icon swap, number pop-in, text swap, success animation, avatar hover, error shake |
Core rules
- Animate for feedback, orientation, continuity, or deliberate delight. If the purpose is "it looks cool" and the user sees it often, don't animate.
- Never animate keyboard-initiated actions (shortcuts, arrow navigation, tab/focus); they repeat hundreds of times daily and animation makes them feel slow.
- Prefer CSS transitions for interruptible UI; keyframes restart from zero on interruption, transitions retarget smoothly. Use keyframes only for predetermined sequences.
- Implementation priority: CSS transitions > WAAPI > CSS keyframes > JS (
requestAnimationFrame). Under load, CSS stays smooth while JS drops frames. - Asymmetric timing: for occasional interactions, enter can be slightly slower and exit should be fast. For high-frequency ephemeral UI (hover highlights, popovers, panel toggles), invert this: enter instantly (0ms), exit with a brief fade (100-150ms) so the action feels immediate.
- Use
@starting-stylefor DOM entry animations; fall back to adata-mountedattribute where support is insufficient. - A small
filter: blur(2px)can hide rough crossfades between swapped content.
Motion design principles
- Continuity over teleportation. Elements visible in both states transition in place. Expand from where elements sit rather than fading in a new instance. Never duplicate a persistent element or hard-cut between views that share components; hard cuts lose spatial context.
- Directional motion matches position. Tab and carousel transitions animate in the direction matching spatial layout (left-to-right for forward, right-to-left for back).
- Emerge from the trigger. Overlays, trays, and panels animate outward from the element that opened them. Generic centre-screen entrances break spatial orientation.
- Consistent polish everywhere. Under-animated areas make the entire product feel unpolished. Motion quality must be uniform across all surfaces.
- Delight scales inversely with frequency. Rarer interactions have more room for personality. High-frequency actions must be invisible.
- Motion enhances perceived speed. Smooth transitions between states feel faster than hard cuts, even at identical load times.
What to animate
- Movement:
transformandopacityonly; they skip layout and paint. - State feedback:
color,background-color, andopacityare acceptable. - Never animate layout properties (
width,height,top,left); they trigger layout recalculation every frame. (Exception: a deliberate container resize tween, see the card-resize recipe.) - Never use
transition: all; it animates unintended properties and silently picks up future ones. List properties explicitly. - Avoid
filteranimation for core interactions; keep blur ≤ 20px if unavoidable, since heavy blur is expensive, especially in Safari. - SVG: apply transforms on a
<g>wrapper withtransform-box: fill-box; transform-origin: center; without it, transforms rotate/scale around the SVG canvas origin. transform: scale()also scales an element's children (icons, text, borders scale proportionally), unlikewidth/height. This is a feature for press feedback; account for it when an inner element must keep a fixed size.- Disable transitions during theme switches (
[data-theme-switching] * { transition: none !important }); otherwise every themed property animates at once.
Easing defaults
| Element | Duration | Easing |
|---|---|---|
| Button press feedback | 100-160ms | cubic-bezier(0.22, 1, 0.36, 1) |
| Tooltips, small popovers | 125-200ms | ease-out or enter curve |
| Dropdowns, selects | 150-250ms | cubic-bezier(0.22, 1, 0.36, 1) |
| Modals, drawers | 200-350ms | cubic-bezier(0.22, 1, 0.36, 1) |
| Move/slide on screen | 200-300ms | cubic-bezier(0.25, 1, 0.5, 1) |
| Page transitions | 250-400ms | enter or move curve |
| Simple hover (colour/opacity) | 200ms | ease |
| Illustrative/marketing | Up to 1000ms | Spring or custom |
Keep routine UI animation under 300ms; scale duration with distance traveled (a full-screen slide can exceed 300ms, a 6px tooltip shift should be under 150ms).
Named curves
- Enter:
cubic-bezier(0.22, 1, 0.36, 1)for entrances and transform-based hover - Move:
cubic-bezier(0.25, 1, 0.5, 1)for slides, drawers, panels - Drawer (iOS-like):
cubic-bezier(0.32, 0.72, 0, 1)
Avoid ease-in for UI; it starts slow, so the element lags the user's action and feels sluggish. Prefer custom curves from easing.dev over built-in ease/ease-out, whose gentle acceleration reads soft rather than decisive.
Transition decision rules
Match the UI element first, then choose the recipe from references/transition-recipes.md:
| UI pattern | Recipe |
|---|---|
| Trigger + floating dot/count | Notification badge |
| Trigger + anchored surface | Menu dropdown |
| Centred surface on top of page | Modal dialog |
| Panel sliding into existing container | Panel reveal |
| List ↔ detail or wizard steps | Page side-by-side slides |
| Element dimension changes | Card resize |
| Text updating in place | Text state swap |
| Two icons in same slot | Icon swap |
| Number updating | Number pop-in |
| Confirmation / success moment | Success celebration |
| Hovering item in horizontal stack | Avatar group hover |
| Form validation error | Error state shake |
Prefer lower-overhead transitions (CSS-only) unless the design requires JS orchestration.
Spatial and sequencing
- Set
transform-originat the trigger point for popovers; keepcenterfor modals (they represent app-level state, not an anchored trigger). - For dialogs/menus, start around
scale(0.85-0.9). Neverscale(0); nothing in the real world appears from nothing. - Stagger reveals at 30-50ms per item; total stagger under 300ms. Vary timing by visual importance; the most important element leads. Uniform stagger removes hierarchy and feels mechanical.
- Paired elements rule: elements that animate together (modal + overlay, tooltip + arrow, FAB + label) must share the same easing curve and duration. Mismatched timing between paired elements is the usual cause of "something feels off".
Accessibility
- Every animation needs a
prefers-reduced-motion: reducepath: disable transform/keyframe motion, keep instant state changes or opacity-only fades. All transition recipes include the guard. - Gate hover animations behind
@media (hover: hover) and (pointer: fine); otherwise touch devices replay hover effects on tap. Tailwind v4hover:utilities apply this guard automatically; skip the manual media query there. - During direct manipulation, keep the element locked to the pointer with no easing. Add easing only after release.
Performance
- Pause looping animations off-screen with
IntersectionObserver; they burn GPU even when invisible. - Toggle
will-changeonly during heavy motion and only fortransform/opacity; remove it after. Each promotion costs compositor memory, and permanent promotion across many elements is worse than none. - Do not animate drag gestures via CSS variables on a container; every update recalculates styles for all children. Set
transformdirectly on the moving element. - Motion
x/yvalues are the normal choice for axis movement and drag (they bypass React re-renders). Use a fulltransformstring only when one owner must combine multiple transform functions or interop with non-Motion code. - See references/performance-deep-dive.md for WAAPI, compositing layers, and the CSS vs JS comparison table.
Anti-patterns
High-signal failures not already covered by the rules above:
- Animating on mount without a user trigger: unexpected motion is disorienting; the user did nothing to cause it.
- Hard stops on drag boundaries feel broken; apply friction/damping so movement diminishes past the boundary (see gesture-drag reference).
- Mixing Motion
x/yprops with a handwrittentransformstring on the same element: both writetransform, so one silently clobbers the other. Pick one transform owner. - Animating both a container and staggering its children: pick one entrance per container. If the panel slides in, its content should already be visible when it arrives.
- Keyframes on rapidly-triggered elements (toasts, list items): interruption restarts them from zero; use CSS transitions, which retarget.
- Tooltip animation after the first tooltip is open: subsequent tooltips in the same group open instantly, or the toolbar feels laggy.
Workflow
Copy and track this checklist:
Animation progress:
- [ ] Step 1: Decide whether the interaction should animate
- [ ] Step 2: Choose purpose, easing, and duration
- [ ] Step 3: Pick the implementation style
- [ ] Step 4: Load the relevant component or technique reference
- [ ] Step 5: Validate timing, interruption, and device behavior1. Answer the four questions in references/decision-framework.md: should it animate? What purpose? What easing? What speed? 2. Pick duration from the easing defaults table above. 3. Choose implementation: CSS transition > WAAPI > spring > keyframe > JS. 4. Load the relevant reference for your component type or technique. 5. When reviewing, apply the strict posture in references/review-format.md: measure against the ten standards, output the Before/After/Why table, then a tiered verdict ending in an explicit Block/Approve decision.
Validation
Produce evidence for each check (DevTools observations, not "looks fine"):
- Grep the diff for layout property transitions (
width,height,top,left) andtransition: all. - Retoggle components rapidly to confirm transitions retarget cleanly instead of restarting from zero.
- Slow animations to 10% in the DevTools Animations panel to catch timing and
transform-originissues invisible at full speed. - Emulate
prefers-reduced-motion: reduce(DevTools Rendering panel) and confirm every animation has a reduced path. - Confirm
will-changeis toggled around animations, not permanently set, and looping animations pause off-screen. - Test touch interactions on real devices; simulators under-report gesture and hover-on-tap issues.
Related skills
reverse-engineer-animation: extracts an animation spec from a screen recording; hand its output here for production implementation.ui-design: visual direction, palettes, typography; settle the visual system before tuning motion.ui-audit: page/feature-level UI quality audit; its motion findings route back to this skill for fixes.animate-text: curated catalog of named text effects (typewriter, line reveal, stagger builds) with exact JSON specs.
clip-path for Animation
clip-path is one of the most powerful animation tools in CSS. It is hardware-accelerated and creates effects impossible with opacity and transform alone.
Contents
The inset shape
clip-path: inset(top right bottom left) defines a rectangular clipping region. Each value "eats" into the element from that side.
/* Fully hidden from right */
.hidden { clip-path: inset(0 100% 0 0); }
/* Fully visible */
.visible { clip-path: inset(0 0 0 0); }Animate between states with a CSS transition:
.reveal {
clip-path: inset(0 100% 0 0);
transition: clip-path 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal.active {
clip-path: inset(0 0 0 0);
}Tab colour transitions
Duplicate the tab list. Style the copy as "active" (different background, different text colour). Clip the copy so only the active tab is visible. Animate the clip on tab change.
This creates a seamless colour transition that timing individual color transitions can never achieve.
.tabs-active-overlay {
clip-path: inset(0 var(--clip-right) 0 var(--clip-left));
transition: clip-path 200ms cubic-bezier(0.22, 1, 0.36, 1);
}Update --clip-left and --clip-right via JavaScript when the active tab changes.
Hold-to-delete
Use clip-path: inset(0 100% 0 0) on a coloured overlay. On :active, transition to inset(0 0 0 0) over 2s with linear timing. On release, snap back with 200ms ease-out. Pair with scale(0.97) on the button for press feedback.
.delete-overlay {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms ease-out;
}
.delete-button:active .delete-overlay {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}Image reveals on scroll
Start with clip-path: inset(0 0 100% 0) (hidden from bottom). Animate to inset(0 0 0 0) when the element enters the viewport.
"use client";
import { useRef, useEffect, useState } from "react";
export function RevealImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) setVisible(true); },
{ threshold: 0.1, rootMargin: "-100px" }
);
io.observe(el);
return () => io.disconnect();
}, []);
return (
<div
ref={ref}
style={{
clipPath: visible ? "inset(0 0 0 0)" : "inset(0 0 100% 0)",
transition: "clip-path 800ms cubic-bezier(0.77, 0, 0.175, 1)",
}}
>
<img src={src} alt={alt} />
</div>
);
}Comparison sliders
Overlay two images. Clip the top image with clip-path: inset(0 50% 0 0). Adjust the right inset based on drag position. No extra DOM elements needed, fully hardware-accelerated.
.comparison-top {
clip-path: inset(0 var(--split) 0 0);
}Update --split via pointer events on the slider handle.
Component Animation Patterns
Contents
- Buttons
- Popovers and dropdowns
- Tooltips
- Drawers and panels
- Modals and dialogs
- Toasts
- Crossfade transitions
- Lists and stagger
- Hover effects
- Step form navigation
- 3D transforms
Buttons
Add transform: scale(0.97) on :active for instant press feedback.
.button {
transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1);
}
.button:active {
transform: scale(0.97);
}scale(0.9) is too aggressive: the button visibly collapses and draws the eye to the shrinking rather than the action. Press feedback should be felt, not seen. Stay in the 0.96-0.98 range.
Use blur to mask imperfect crossfade transitions between button states:
.button-content.transitioning {
filter: blur(2px);
opacity: 0.7;
}Keep blur under 20px; heavy blur is expensive, especially in Safari.
Popovers and dropdowns
Scale in from the trigger point, not from center. The default transform-origin: center is wrong for popovers.
/* Radix UI */
.popover {
transform-origin: var(--radix-popover-content-transform-origin);
}
/* Data attribute fallback */
.popover[data-side="top"] { transform-origin: bottom center; }
.popover[data-side="bottom"] { transform-origin: top center; }
.popover[data-side="left"] { transform-origin: center right; }
.popover[data-side="right"] { transform-origin: center left; }Start at scale(0.88), never scale(0). Nothing in the real world appears from nothing.
.menu {
transform: scale(0.88);
opacity: 0;
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.menu[data-open="true"] {
transform: scale(1);
opacity: 1;
}Tooltips
Delay before first appearance (300-500ms) to prevent accidental activation. Once one tooltip is open, subsequent tooltips open instantly with no animation.
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
opacity: 0;
transform: scale(0.97);
}
.tooltip[data-instant] {
transition-duration: 0ms;
}Drawers and panels
Use the move easing curve. Percentage translateY/translateX adapts to any drawer height.
.drawer {
transform: translateY(100%);
transition: transform 240ms cubic-bezier(0.25, 1, 0.5, 1);
}
.drawer[data-open="true"] {
transform: translateY(0);
}<motion.aside
initial={{ transform: "translate3d(100%, 0, 0)" }}
animate={{ transform: "translate3d(0, 0, 0)" }}
exit={{ transform: "translate3d(100%, 0, 0)" }}
transition={{ duration: 0.24, ease: [0.25, 1, 0.5, 1] }}
/>Modals and dialogs
Exception: modals keep `transform-origin: center`. They represent app-level state, not anchored to a trigger.
Use @starting-style for entry animations without JavaScript:
.modal {
opacity: 1;
transform: scale(1);
transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
transform 250ms cubic-bezier(0.22, 1, 0.36, 1);
@starting-style {
opacity: 0;
transform: scale(0.95);
}
}Fall back to data-mounted attribute pattern when @starting-style browser support is insufficient.
Toasts
Enter and exit from the same direction for spatial consistency (makes swipe-to-dismiss intuitive).
.toast {
transform: translate3d(0, 6px, 0);
opacity: 0;
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.toast[data-open="true"] {
transform: translate3d(0, 0, 0);
opacity: 1;
}Use CSS transitions (not keyframes) for toasts; they are added rapidly, and keyframes restart on interruption while transitions retarget smoothly.
Crossfade transitions
When the container is small or the outgoing and incoming content are structurally similar, a full directional slide adds too much visual weight. Use a crossfade with a subtle directional hint instead.
.view-enter {
opacity: 0;
transform: translateY(8px);
filter: blur(4px);
transition: opacity 150ms ease-out, transform 150ms ease-out, filter 150ms ease-out;
}
.view-enter-active {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}Nav content swaps, tab panels with similar structure, and small card state changes are crossfade candidates. The 8px shift communicates "the view changed" without the visual weight of content traveling across the screen.
Lists and stagger
Keep stagger delays short (30-50ms per item). Total stagger should stay under 300ms.
.item {
opacity: 0;
transform: translateY(8px);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.list[data-open="true"] .item {
opacity: 1;
transform: translateY(0);
}
.list[data-open="true"] .item:nth-child(2) { transition-delay: 50ms; }
.list[data-open="true"] .item:nth-child(3) { transition-delay: 100ms; }
.list[data-open="true"] .item:nth-child(4) { transition-delay: 150ms; }const listVariants = {
show: { transition: { staggerChildren: 0.05 } },
};Never block interaction while stagger animations are playing.
When removing items, use AnimatePresence mode="popLayout" so the exiting element is pulled out of document flow immediately. Siblings start reflowing in parallel with the exit animation. The default mode waits for exit to finish before siblings move, causing sequential rather than parallel motion.
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.div
key={item.id}
layout
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.15 }}
/>
))}
</AnimatePresence>Hover effects
Gate hover animations behind a media query to avoid false positives on touch devices.
@media (hover: hover) and (pointer: fine) {
.link {
transition: color 200ms ease, opacity 200ms ease;
}
.link:hover {
opacity: 0.8;
}
}Fix hover flicker by applying the hover on a parent and animating the child. When translateY is on the hover target itself, the element moves out from under the cursor at the bottom edge, ending the hover, causing an infinite loop.
.box:hover .box-inner {
transform: translateY(-20%);
}
.box-inner {
transition: transform 200ms ease;
}For scale-based hover, use scale(1.01) to scale(1.02). scale(1.05) is visibly inflated. Hover transitions should be 100-150ms; 300ms feels laggy because the user's eye is already on the element.
@media (hover: hover) and (pointer: fine) {
.card {
transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1);
}
.card:hover {
transform: scale(1.015);
}
}Step form navigation
Forward steps slide content left (like reading). Backward steps slide content right (like undoing). Animating both directions the same way breaks the user's mental model of forward vs backward progress.
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 100 : -100,
opacity: 0,
}),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({
x: direction > 0 ? -100 : 100,
opacity: 0,
}),
};
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={step}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
</AnimatePresence>3D transforms
For depth effects (card flips, coin spins, orbits), use rotateX()/rotateY() with transform-style: preserve-3d on the wrapper. This stays on the GPU and needs no JavaScript. Reserve it for illustrative or delight moments, not high-frequency UI.
.flip {
transform-style: preserve-3d;
transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1);
}
.flip[data-flipped="true"] {
transform: rotateY(180deg);
}
.flip .front,
.flip .back {
backface-visibility: hidden;
}
.flip .back {
transform: rotateY(180deg);
}Set perspective on the parent (for example perspective: 1000px) to control depth intensity; smaller values exaggerate the effect. As with SVG, set transform-box: fill-box; transform-origin: center if the rotation pivots around the wrong point.
Contextual Animations
Patterns for icon swaps, word-level stagger entrances, and subtle exits.
Contents
---
Contextual icon swaps
When icons change state contextually (copy → check, play → pause, send → sent), animate opacity, scale, and blur together. This makes the swap feel responsive rather than instant. Blur hides the crossfade seam between the outgoing and incoming icon.
Motion (preferred, supports springs):
import { AnimatePresence, motion } from "motion/react"
<button onClick={handleCopy}>
<AnimatePresence mode="wait" initial={false}>
{isCopied ? (
<motion.span
key="check"
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.2, bounce: 0 }}
>
<CheckIcon />
</motion.span>
) : (
<motion.span
key="copy"
initial={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.8, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.2, bounce: 0 }}
>
<CopyIcon />
</motion.span>
)}
</AnimatePresence>
</button>CSS only:
.icon {
transition:
opacity 150ms ease,
scale 150ms ease,
filter 150ms ease;
}
.icon[data-hidden] {
opacity: 0;
scale: 0.8;
filter: blur(4px);
pointer-events: none;
}Use mode="wait" in AnimatePresence so the exit finishes before the enter starts, preventing both icons from being visible simultaneously.
---
Word-level stagger entrances
For entrance animations on hero text or page headers, split the content into sections (or individual words) and animate each with a staggered delay. Combining opacity + translateY + blur is necessary; each property alone looks flat, mechanical, or cheap.
Two levels of stagger:
| Level | Delay | Use for |
|---|---|---|
| Section-level | 100ms per section | Title block, description block, button group |
| Word-level | 80ms per word | Hero headline only |
CSS pattern:
@keyframes enter {
from {
transform: translateY(8px);
filter: blur(5px);
opacity: 0;
}
}
.animate-enter {
animation: enter 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: calc(var(--delay, 0ms) * var(--stagger, 0));
}
/* Section level: 100ms gaps */
.animate-enter-section {
--delay: 100ms;
}
/* Word level: 80ms gaps */
.animate-enter-word {
--delay: 80ms;
}Section-level JSX:
<div className="animate-enter animate-enter-section" style={{ "--stagger": 1 }}>
<Title />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 2 }}>
<Description />
</div>
<div className="animate-enter animate-enter-section" style={{ "--stagger": 3 }}>
<Buttons />
</div>Word-level JSX:
{"Track expenses, build habits".split(" ").map((word, i) => (
<span
key={word}
className="animate-enter animate-enter-word inline-block"
style={{ "--stagger": i + 1 }}
>
{word}
</span>
))}These values differ from the general-purpose 30-50ms item stagger in component-patterns.md. Use 30-50ms for lists; use 80-100ms for page-level entrances where each chunk carries narrative weight.
---
Subtle exit animations
Exit animations should be directional (to indicate where content is going) but should not demand the same attention as enter animations. Use a fixed small offset rather than computing the full element height.
Full exit (too much movement for overlays):
<motion.div
exit={{
opacity: 0,
y: "calc(-100% - 4px)", // the full height, plus gap
filter: "blur(4px)",
}}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>Subtle exit (recommended):
<motion.div
initial={{ opacity: 0, y: "calc(-100% - 4px)", filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{
opacity: 0,
y: "-12px", // fixed value, regardless of element height
filter: "blur(4px)",
}}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
/>The -12px value is intentionally fixed; do not calculate it from element dimensions. The goal is to convey direction, not to trace the full exit path. The enter animation uses the full distance to build presence; the exit uses a short fixed distance to release attention quietly.
Spring config: { type: "spring", duration: 0.45, bounce: 0 }, zero bounce for a clean, controlled exit.
Animation Decision Framework
Contents
- 1. Should this animate at all?
- 2. What is the purpose?
- 3. What easing should it use?
- 4. How fast should it be?
Before writing any animation code, answer these four questions in order.
1. Should this animate at all?
How often will users see this animation?
| Frequency | Examples | Decision |
|---|---|---|
| 100+ times/day | Keyboard shortcuts, command palette toggle | No animation. Ever. |
| Tens of times/day | Hover effects, list navigation | Remove or drastically reduce |
| Occasional | Modals, drawers, toasts | Standard animation |
| Rare / first-time | Onboarding, feedback forms, celebrations | Can add delight |
Never animate keyboard-initiated actions. They repeat hundreds of times daily; animation makes them feel slow and disconnected.
2. What is the purpose?
Answer "why does this animate?" before writing code.
| Purpose | Description | Example |
|---|---|---|
| Feedback | Confirms user action was received | Button scale on press, toggle state |
| Orientation | Shows spatial relationship | Drawer slides from edge, menu scales from trigger |
| Continuity | Preserves context across state changes | Page transitions, layout shifts |
| Delight | Adds personality (use sparingly) | Stagger reveals, spring overshoot |
If the purpose is just "it looks cool" and the user will see it often, don't animate.
3. What easing should it use?
Follow this decision tree:
- Entering the viewport? → enter curve:
cubic-bezier(0.22, 1, 0.36, 1) - Exiting the viewport? → same curve, shorter duration
- Moving/sliding on screen? → move curve:
cubic-bezier(0.25, 1, 0.5, 1) - Simple hover (color/opacity)? →
200ms ease - Needs physics feel? → spring
- Direct manipulation (drag)? → no easing, follow the pointer
- Constant motion (marquee, spinner)? →
linear
Avoid ease-in for UI; it starts slow and feels sluggish. CSS's built-in named curves (ease-out, ease) have gentle acceleration that makes animations feel soft rather than decisive. Custom curves like cubic-bezier(0.22, 1, 0.36, 1) have steeper initial acceleration: the element covers most of its distance in the first third, so the same 200ms feels significantly faster.
Easing resources: easing.dev and easings.co for stronger custom variants.
Extended easing reference
| Name | Curve | Character |
|---|---|---|
| ease-out-quad | cubic-bezier(0.25, 0.46, 0.45, 0.94) | Gentle deceleration |
| ease-out-cubic | cubic-bezier(0.22, 0.61, 0.36, 1) | Standard deceleration |
| ease-out-quart | cubic-bezier(0.165, 0.84, 0.44, 1) | Strong deceleration |
| ease-out-quint | cubic-bezier(0.23, 1, 0.32, 1) | Very strong deceleration |
| ease-out-expo | cubic-bezier(0.19, 1, 0.22, 1) | Explosive start, soft land |
| ease-out-circ | cubic-bezier(0.075, 0.82, 0.165, 1) | Circular deceleration |
| ease-in-out-quad | cubic-bezier(0.455, 0.03, 0.515, 0.955) | Gentle symmetric |
| ease-in-out-cubic | cubic-bezier(0.645, 0.045, 0.355, 1) | Standard symmetric |
| ease-in-out-quart | cubic-bezier(0.77, 0, 0.175, 1) | Strong symmetric |
Use weaker curves (quad, cubic) for small or frequent elements. Use stronger curves (quint, expo) for large or rare transitions.
Asymmetric vs symmetric curves
Symmetric ease-in-out starts slow; there's a noticeable lag between the user's action and the element beginning to move. For interactive elements (drawers, panels, menus), use asymmetric curves that are steep at the start and settle slowly at the end. This preserves responsiveness while the slow deceleration adds quality.
Duration and easing are inseparable. A steep curve can afford a longer duration because the movement is front-loaded. Vaul's drawer uses 500ms with cubic-bezier(0.32, 0.72, 0, 1), but it doesn't feel slow because the drawer covers most of its distance in the first 200ms.
4. How fast should it be?
Pick the duration from the easing defaults table in SKILL.md. Keep routine UI animation under 300ms and scale duration with distance traveled: a full-screen menu sliding from off-screen can exceed 300ms, while a 6px tooltip shift should be under 150ms.
Perceived performance
Animation speed changes perceived performance:
- A fast-spinning spinner makes loading feel faster (same load time, different perception)
ease-outat 200ms _feels_ faster thanease-inat 200ms because the user sees immediate movement- Instant tooltips after the first one is open (skip delay + skip animation) make the toolbar feel faster
Asymmetric timing
Enter can be slightly slower than exit. Example: hold-to-delete uses 2s linear on press, 200ms ease-out on release.
/* Release: fast */
.overlay {
transition: clip-path 200ms ease-out;
}
/* Press: slow and deliberate */
.button:active .overlay {
transition: clip-path 2s linear;
}Instant enter, animated exit (productivity tools)
For high-frequency interactions in daily-driver productivity tools (hover highlights, popovers, side panels, command palette results), invert the standard asymmetric rule: enter instantly (0ms), exit with a brief fade (100-150ms).
The user's action should produce an immediate visual result; any enter delay feels like lag when repeated hundreds of times. The exit animation prevents the dismissal from feeling jarring (a hard cut on exit is more noticeable than on enter because the user's eye is already on the element).
/* Hover highlight: instant appear, soft dismiss */
.highlight {
transition: opacity 0.15s ease-out;
opacity: 0;
}
.item:hover .highlight {
transition-duration: 0s;
opacity: 1;
}This applies when:
- The interaction happens tens to hundreds of times per day
- The user initiates the action (hover, click, keyboard)
- The element is ephemeral (highlight, popover, tooltip after first open)
It does not apply to:
- Rare interactions (modals, onboarding): use standard asymmetric timing
- Content that needs orientation (drawers with navigation): enter animation provides spatial context
Once you know the element should animate, match the UI pattern to a recipe using the "Transition decision rules" table in SKILL.md.
Gesture and Drag Animations
Patterns for drag, swipe, and gesture interactions where the user directly manipulates elements.
Contents
- Momentum-based dismissal
- Boundary damping
- Pointer capture
- Multi-touch protection
- Friction vs hard stops
- Swipe-to-dismiss pattern
Momentum-based dismissal
Don't require dragging past a distance threshold. Calculate velocity at release; a quick flick should be enough to dismiss.
function onPointerUp(e: PointerEvent) {
const timeTaken = Date.now() - dragStartTime;
const velocity = Math.abs(swipeAmount) / timeTaken;
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
dismiss();
} else {
snapBack();
}
}Use velocity > 0.11 as a reasonable default threshold. Combine with a minimum distance threshold (e.g. 20px) to prevent accidental dismissals.
Boundary damping
When a user drags past the natural boundary (e.g. pulling a drawer up when already at top), apply damping. The more they drag, the less the element moves.
function applyDamping(offset: number, max: number): number {
return max * (1 - Math.exp(-offset / max));
}
// Usage: as offset grows, movement diminishes
const dampedOffset = applyDamping(rawOffset, 200);Things in real life don't suddenly stop; they slow down first. Friction instead of hard stops always feels more natural.
Pointer capture
Once dragging starts, capture all pointer events on the element. This ensures dragging continues even if the pointer leaves the element bounds.
function onPointerDown(e: PointerEvent) {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
isDragging = true;
}
function onPointerUp(e: PointerEvent) {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
isDragging = false;
}Always use setPointerCapture; without it, fast swipes escape the element and the drag breaks.
Multi-touch protection
Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump.
let activeTouchId: number | null = null;
function onPointerDown(e: PointerEvent) {
if (activeTouchId !== null) return; // Ignore additional touches
activeTouchId = e.pointerId;
// Start drag...
}
function onPointerUp(e: PointerEvent) {
if (e.pointerId !== activeTouchId) return;
activeTouchId = null;
// End drag...
}Friction vs hard stops
Instead of preventing drag past a boundary, allow it with increasing friction:
function applyFriction(delta: number, isAtBoundary: boolean): number {
if (!isAtBoundary) return delta;
return delta * 0.3; // 30% of movement at boundary
}Hard stops feel broken; users expect physics. Apply friction for scroll containers, sliders, and drawers.
Swipe-to-dismiss pattern
Combine velocity, distance, and direction for a complete swipe gesture:
function handleSwipeEnd(direction: "left" | "right", distance: number, velocity: number) {
const shouldDismiss = distance > THRESHOLD || velocity > 0.11;
if (shouldDismiss) {
// Animate out in swipe direction with remaining momentum
animateOut(direction, velocity);
} else {
// Spring back to origin
springBack();
}
}The exit animation should continue in the swipe direction with momentum; snapping to a different direction feels wrong.
Performance Deep Dive
Advanced performance guidance beyond the quick rules in SKILL.md.
Contents
- CSS vs JS animations
- Web Animations API (WAAPI)
- CSS variables inheritance trap
- Motion transform ownership
- Pause looping animations off-screen
- Compositing layers and will-change
- Fix shaky 1px shifts
CSS vs JS animations
| Approach | Driver | Interruptible | Best for |
|---|---|---|---|
| CSS transitions | Browser/compositor for transform/opacity | Yes (retargets) | Predetermined state changes |
| CSS keyframes | Browser/compositor when properties allow it | No (restarts from zero) | Looping, predetermined sequences |
WAAPI (el.animate()) | Browser animation engine | Yes (cancel/reverse) | Dynamic values with imperative control |
Motion values (x, y, style) | Motion DOM renderer, no React re-renders | Yes | React gestures, drag, coordinated UI |
JS (requestAnimationFrame) | Main thread | Yes (manual) | Complex choreography, physics |
Rule: CSS transitions > WAAPI > CSS keyframes > JS. Under load (page navigation, heavy rendering), CSS animations stay smooth while JS animations drop frames.
Web Animations API (WAAPI)
JavaScript control with CSS performance. Hardware-accelerated, interruptible, promise-based.
const animation = element.animate(
[
{ transform: "translateY(100%)", opacity: 0 },
{ transform: "translateY(0)", opacity: 1 },
],
{
duration: 300,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
fill: "forwards",
}
);
// Cancel or reverse at any time
animation.reverse();
await animation.finished;CSS variables inheritance trap
Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating --swipe-amount on the container causes expensive style recalculation.
// Bad: triggers recalc on all children
element.style.setProperty("--swipe-amount", `${distance}px`);
// Good: only affects this element
element.style.transform = `translateY(${distance}px)`;Exception: @property with inherits: false avoids the cascade, but browser support is limited.
Motion transform ownership
Motion's x/y values are first-class APIs for single-axis movement and drag. They update without React re-renders and are the default choice for gesture-heavy components.
const x = useMotionValue(0);
// Idiomatic Motion API for drag and axis movement
<motion.div drag="x" style={{ x }} />
// Use one handwritten transform string when you need to author
// multiple transform functions together or interop with non-Motion code
<motion.div animate={{ transform: "translateX(100px) rotate(4deg)" }} />Do not mix Motion x/y props with a separate handwritten transform string on the same element. Pick one transform owner.
Pause looping animations off-screen
Looping animations consume GPU resources even when not visible.
"use client";
import { useEffect, useRef } from "react";
export function usePauseOffscreen<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
el.style.animationPlayState = entry.isIntersecting ? "running" : "paused";
});
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}Compositing layers and will-change
will-change creates a new compositor layer; this has a memory cost.
- Only promote during animation, remove after
- Only for
transformandopacity - Too many layers is worse than no promotion
.animating { will-change: transform, opacity; }Toggle the class on animation start, remove on transitionend or animationend.
Fix shaky 1px shifts
Elements can shift by 1px at animation start/end due to GPU/CPU handoff. Apply will-change: transform during the animation (not permanently) to keep compositing on the GPU throughout.
Animation Review Format
Contents
- Operating posture
- Ten non-negotiable standards
- Escalation triggers
- Remedial preference hierarchy
- Before/After/Why table
- Review checklist
- Verdict output
- Component design principles
- Debugging animations
Operating posture
You are a senior motion reviewer with a brutal eye for craft. The bias is toward motion that feels right, not motion that merely runs. A transition that "works" but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging. Approval is earned, not assumed.
Ten non-negotiable standards
Every animation in the diff is measured against these. A violation is a finding. For exact values (curves, durations, spring config), cite the easing/duration tables in the main SKILL.md rather than approximating.
1. Justified motion. Every animation answers "why does this animate?": feedback, orientation, continuity, state indication, or deliberate delight. "It looks cool" on a frequently-seen element is a block. 2. Frequency-appropriate. Keyboard-initiated and 100+/day actions get no animation. Tens/day gets reduced motion. Occasional gets standard. Rare or first-time can carry delight. 3. Responsive easing. Entering/exiting elements use ease-out or a strong custom curve. ease-in on UI is a block; it delays the moment the user watches most. Built-in CSS easings are too weak for deliberate animation. 4. Sub-300ms UI. UI animations stay under 300ms; anything slower on a UI element needs a stated reason. Scale duration with distance traveled. 5. Origin and physical correctness. Popovers, dropdowns, and tooltips scale from their trigger (transform-origin), not center. Never animate from scale(0); start at scale(0.85-0.97) plus opacity. Modals are exempt and stay centered. 6. Interruptibility. Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must retarget from its current state. Prefer CSS transitions or springs over keyframes, which restart from zero. 7. GPU-only properties. Animate transform and opacity only. Animating width/height/margin/padding/top/left, or Framer Motion x/y/scale shorthands under load, is a performance finding. 8. Accessibility. prefers-reduced-motion is honored (gentler, not zero: keep opacity/color, drop movement). Hover animations are gated behind @media (hover: hover) and (pointer: fine). 9. Asymmetric enter/exit. Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Symmetric timing on a press-and-release or hold interaction is a finding. 10. Cohesion. Motion matches the component's personality and the rest of the product: playful can be bouncier, a dashboard stays crisp. Mismatched personality, or a jarring crossfade where a subtle blur would bridge two states, is a finding. When unsure whether motion feels right, the strongest move is often to delete it.
Escalation triggers
Flag these on sight, hard:
transition: all(unbounded property animation)scale(0)or pure-fade entrances with no initial transformease-inon any UI interaction, or weak built-in easing on a deliberate animation- Animation on a keyboard shortcut, command-palette toggle, or 100+/day action
- UI duration > 300ms with no stated reason
transform-origin: centeron a trigger-anchored popover, dropdown, or tooltip- Keyframes on toasts, toggles, or anything added/triggered rapidly
- Animating layout properties (
width/height/margin/padding/top/left) - Framer Motion
x/y/scaleprops on motion that runs while the page is busy - Updating a CSS variable on a parent to drive a child transform (style recalc storm)
- Missing
prefers-reduced-motionhandling on movement - Ungated
:hovermotion - Symmetric enter/exit timing on a press-and-release or hold interaction
- Everything-at-once entrance where a 30-50ms stagger belongs
Remedial preference hierarchy
When proposing fixes, prefer earlier moves over later ones:
1. Delete the animation (high-frequency, no purpose, or keyboard-triggered). 2. Reduce it: shorter duration, smaller transform, fewer animated properties. 3. Fix the easing: swap ease-in to ease-out or a strong custom curve. 4. Fix the origin and physicality: correct transform-origin; replace scale(0) with scale(0.95) plus opacity. 5. Make it interruptible: keyframes to transitions, or a spring for gesture-driven motion. 6. Move it to the GPU: layout props to transform/opacity; shorthand to a full transform string; WAAPI for programmatic CSS. 7. Asymmetric timing: slow the deliberate phase, snap the response. 8. Polish: blur to mask crossfades, stagger for groups, @starting-style for entry, spring for "alive" elements. 9. Accessibility and cohesion: add reduced-motion and hover gating; tune to match the component's personality.
Before/After/Why table
The required first part of every review. Use a markdown table, one row per issue. Never a "Before:/After:" list on separate lines.
| Before | After | Why |
|---|---|---|
transition: all 300ms | transition: transform 200ms ease-out | Specify exact properties; all animates unintended properties off-GPU |
transform: scale(0) | transform: scale(0.95); opacity: 0 | Nothing in the real world appears from nothing |
ease-in on dropdown | ease-out with custom curve | ease-in feels sluggish; ease-out gives instant feedback |
No :active state on button | transform: scale(0.97) on :active | Buttons must feel responsive to press |
transform-origin: center on popover | transform-origin: var(--radix-popover-content-transform-origin) | Popovers scale from trigger (modals stay centered) |
Review checklist
| Issue | Fix |
|---|---|
transition: all | Target specific properties |
Layout property animated (width, height, top, left) | Switch to transform and opacity |
ease-in on UI entrance | Use enter easing: cubic-bezier(0.22, 1, 0.36, 1) |
Permanent will-change | Toggle during animation only |
scale(0) start | Use scale(0.85-0.95) with opacity: 0 |
| No touch device guard on hover | Add @media (hover: hover) and (pointer: fine) |
| Symmetric enter/exit timing | Make exit 20-30% faster than enter |
| CSS variable drag animation | Use transform directly on the element |
Missing setPointerCapture on drag | Add pointer capture for reliable tracking |
Motion x/y mixed with a handwritten transform | Pick one transform owner for the element |
| Animation on keyboard action | Remove animation entirely |
| Duration > 300ms on UI element | Reduce to 150-250ms |
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
| Hard cut between views sharing elements | Add shared-element transition; animate persistent components in place |
| Contextual overlay enters from centre | Set transform-origin to trigger; animate outward from source element |
| Elements all appear at once | Add stagger delay (30-50ms between items) |
| Touch target under 44px on interactive element | Add ::before pseudo-element sized to 44x44px minimum (WCAG 2.5.5) |
| Hover scale > 1.03 or hover duration > 150ms | Use scale(1.01-1.02) and 100-150ms transition |
| Container animates AND children stagger | Pick one entrance: animate the container OR stagger children, not both |
Missing close-state cleanup after setTimeout | Add is-closing class, remove after transition duration completes |
Missing reflow (void el.offsetWidth) between class changes | Force reflow before re-adding classes to restart transitions |
| Animating container instead of inner pieces | Apply transitions to child elements, not the wrapper |
Hardcoded stroke-dasharray on SVG success path | Use path.getTotalLength() to measure the actual path length |
.is-error and .is-shaking merged into one class | Keep them separate: .is-shaking controls animation only, .is-error controls visual state |
Verdict output
The required second part of every review. Group remaining commentary by impact tier, highest first. Omit empty tiers.
1. Feel-breaking regressions: sluggish easing, comes-from-nowhere entrances, motion that fires on high-frequency or keyboard actions. 2. Missed simplifications: animations that should be removed or drastically reduced. 3. Performance: non-GPU properties, dropped-frame risks, recalc storms. 4. Interruptibility and timing: keyframes where transitions/springs belong; symmetric timing that should be asymmetric. 5. Origin, physicality, and cohesion: wrong origin, mismatched personality, jarring crossfades. 6. Accessibility: reduced-motion and pointer/hover gating.
Close with an explicit decision, citing file:line:
- Block: any feel-breaking regression, animation on a keyboard or high-frequency action,
scale(0)orease-inon UI, or a non-GPU animation with an easy GPU fix. - Approve: no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected.
Component design principles
- Good defaults over options. Most users never customise. The default easing, timing, and design should be excellent out of the box.
- Transitions over keyframes for dynamic UI. Elements added rapidly (toasts, list items) need interruptible animations. Keyframes restart from zero on interruption; transitions retarget smoothly.
- Cohesion. The animation style should match the component's personality. A playful component can be bouncier. A professional dashboard should be crisp and fast.
- Invisible edge cases. Pause timers when the tab is hidden. Fill gaps between stacked elements with pseudo-elements to maintain hover state. Capture pointer events during drag.
Debugging animations
- Slow motion: Temporarily increase duration to 2-5x or use the browser animation inspector. Check colour timing, easing, and transform-origin.
- Frame-by-frame: Step through in Chrome DevTools Animations panel to reveal timing issues between coordinated properties.
- Real devices: For touch interactions (drawers, swipe gestures), test on physical devices. The Xcode Simulator works but real hardware is better for gesture testing.
- Review next day: You notice imperfections with fresh eyes that you missed during development.
Spring Animations
Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations; they settle based on physical parameters.
When to use springs
- Drag interactions with momentum (release and let physics take over)
- Elements that should feel "alive" (like Apple's Dynamic Island)
- Gestures that can be interrupted mid-animation
- Decorative mouse-tracking interactions
- Overshoot effects (playful UI)
Don't use springs for: simple fades, color transitions, or UI that needs precise timing.
Spring parameters
| Parameter | What it controls | Typical range |
|---|---|---|
stiffness | Speed of movement (higher = faster) | 100-500 |
damping | Resistance (lower = more bounce) | 15-40 |
mass | Weight feel (higher = slower, heavier) | 0.5-2 |
Configuration presets
Apple-style (recommended, easier to reason about):
{ type: "spring", duration: 0.5, bounce: 0.2 }Traditional physics (more control):
| Preset | stiffness | damping | Use case |
|---|---|---|---|
| Snappy (Apple default) | 500 | 40 | General UI, no bounce |
| Bouncy | 300 | 20 | Playful elements, notifications |
| Gentle | 200 | 30 | Page transitions, large elements |
| Stiff | 700 | 50 | Small precise movements |
Bounce communicates brand personality. Default to zero; it is the safe choice. A finance dashboard should never bounce; a learning app or creative tool can use subtle bounce (0.1-0.2) to feel friendlier. The question is not "does it look better with bounce?" but "does this match the brand?"
Interruptibility advantage
Springs maintain velocity when interrupted; CSS keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion.
// Spring reverses smoothly from current position
<motion.div
animate={{ transform: isOpen ? "translateX(0)" : "translateX(-100%)" }}
transition={{ type: "spring", stiffness: 500, damping: 40 }}
/>Spring-based mouse interactions
Tying values directly to mouse position feels artificial. Use useSpring to interpolate with spring-like behaviour instead of updating immediately.
import { useSpring } from "framer-motion";
// Without spring: instant, feels artificial
const rotation = mouseX * 0.1;
// With spring: has momentum, feels natural
const springRotation = useSpring(mouseX * 0.1, {
stiffness: 100,
damping: 10,
});Only use this for decorative interactions. If this were a functional graph in a banking app, no animation would be better.
Snap instead of spring
If the interaction needs instant response or precise timing, skip the spring entirely. Use a short transition or snap directly to the end state.
<motion.div
animate={{ opacity: isOpen ? 1 : 0, x: isOpen ? 0 : -12 }}
transition={
shouldSnap
? { duration: 0.12, ease: "linear" }
: { type: "spring", stiffness: 500, damping: 40 }
}
/>CSS Transition Recipes
12 production-ready CSS transition patterns. Each recipe includes CSS, HTML hooks, JS orchestration where needed, and a prefers-reduced-motion guard. All recipes read from a shared :root custom properties block.
Contents
- Custom properties
- Card resize
- Panel reveal
- Notification badge
- Icon swap
- Menu dropdown
- Modal dialog
- Text state swap
- Page side-by-side slides
- Number pop-in
- Avatar group hover
- Success celebration
- Error state shake
---
Custom properties
Add this :root block once to your global stylesheet. Every recipe reads from these names.
:root {
/* Card resize */
--resize-dur: 300ms;
--resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Number pop-in */
--digit-dur: 500ms;
--digit-dist: 12px;
--digit-stagger: 70ms;
--digit-blur: 6px;
--digit-ease: cubic-bezier(0.22, 1, 0.36, 1);
--digit-dir-x: 0;
--digit-dir-y: 1;
/* Notification badge */
--badge-slide-dur: 260ms;
--badge-pop-dur: 500ms;
--badge-blur: 2px;
--badge-offset-x: -8.2px;
--badge-offset-y: 12.4px;
--badge-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Text state swap */
--text-swap-dur: 150ms;
--text-swap-y: 4px;
--text-swap-blur: 2px;
--text-swap-ease: ease-in-out;
/* Menu dropdown */
--dropdown-open-dur: 250ms;
--dropdown-close-dur: 150ms;
--dropdown-pre-scale: 0.96;
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Modal dialog */
--modal-open-dur: 250ms;
--modal-close-dur: 150ms;
--modal-scale: 0.96;
--modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Panel reveal */
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-y: 12px;
--panel-blur: 4px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Page side-by-side */
--page-dur: 200ms;
--page-dist: 8px;
--page-blur: 3px;
--page-stagger: 60ms;
--page-exit-enabled: 1;
--page-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Icon swap */
--icon-swap-dur: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
/* Success celebration */
--success-opacity-dur: 550ms;
--success-rotate-dur: 550ms;
--success-bob-dur: 550ms;
--success-blur-dur: 400ms;
--success-path-dur: 550ms;
--success-path-delay: 80ms;
--success-rotate-from: 80deg;
--success-rotate-to: 0deg;
--success-bob-y: 40px;
--success-blur-from: 10px;
--success-ease: cubic-bezier(0.22, 1, 0.36, 1);
--success-bob-ease: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Avatar group hover */
--avatar-lift: -4px;
--avatar-dur: 320ms;
--avatar-scale: 1.05;
--avatar-falloff: 0.45;
--avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
--avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Error state shake */
--shake-dist: 4px;
--shake-overshoot: 2px;
--shake-dur-1: 80ms;
--shake-dur-2: 80ms;
--shake-dur-3: 60ms;
--shake-ease: cubic-bezier(0.36, 0.07, 0.19, 0.97);
--shake-revert-dur: 200ms;
--shake-hold: 1200ms;
}---
Card resize
Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS required.
<div class="t-resize">Content</div>.t-resize {
transition: width var(--resize-dur) var(--resize-ease),
height var(--resize-dur) var(--resize-ease);
will-change: width, height;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.t-resize { transition: none; }
}Toggle dimensions with a state class or inline style. The transition handles the tween.
---
Panel reveal
Slide a panel into view inside an existing container with a cross-blur effect. CSS only, toggle data-open.
See also: component-patterns.md § Drawers and panels for percentage-based drawer slides.
<div class="t-panel" data-open="false">Panel content</div>.t-panel {
opacity: 0;
transform: translateY(var(--panel-translate-y));
filter: blur(var(--panel-blur));
transition: opacity var(--panel-open-dur) var(--panel-ease),
transform var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel[data-open="true"] {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.t-panel[data-open="false"] {
transition-duration: var(--panel-close-dur);
}
@media (prefers-reduced-motion: reduce) {
.t-panel { transition: none; }
}---
Notification badge
Slide a small badge onto a trigger (button, icon) and pop the dot. The trigger stays put. CSS only, toggle data-open.
<button style="position: relative">
Inbox
<span class="t-badge" data-open="false">
<span class="t-badge-dot"></span>
</span>
</button>.t-badge {
position: absolute;
opacity: 0;
transform: translate(var(--badge-offset-x), var(--badge-offset-y));
filter: blur(var(--badge-blur));
transition: opacity var(--badge-slide-dur) var(--badge-ease),
transform var(--badge-slide-dur) var(--badge-ease),
filter var(--badge-slide-dur) var(--badge-ease);
}
.t-badge[data-open="true"] {
opacity: 1;
transform: translate(0, 0);
filter: blur(0);
}
.t-badge-dot {
display: block;
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
transform: scale(0);
transition: transform var(--badge-pop-dur) var(--badge-ease);
}
.t-badge[data-open="true"] .t-badge-dot {
transform: scale(1);
transition-delay: calc(var(--badge-slide-dur) * 0.5);
}
@media (prefers-reduced-motion: reduce) {
.t-badge, .t-badge-dot { transition: none; animation: none; }
}---
Icon swap
Cross-fade two icons in the same slot (hamburger/close, play/pause). CSS grid stacks both icons. Toggle data-state.
See also: contextual-animations.md § Contextual icon swaps for the Motion/AnimatePresence approach.
<span class="t-icon-swap" data-state="a">
<span class="t-icon" data-icon="a">☰</span>
<span class="t-icon" data-icon="b">✕</span>
</span>.t-icon-swap {
display: inline-grid;
}
.t-icon {
grid-area: 1 / 1;
opacity: 0;
transform: scale(var(--icon-swap-start-scale));
filter: blur(var(--icon-swap-blur));
transition: opacity var(--icon-swap-dur) var(--icon-swap-ease),
transform var(--icon-swap-dur) var(--icon-swap-ease),
filter var(--icon-swap-dur) var(--icon-swap-ease);
}
.t-icon-swap[data-state="a"] [data-icon="a"],
.t-icon-swap[data-state="b"] [data-icon="b"] {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.t-icon { transition: none; }
}---
Menu dropdown
Origin-aware dropdown with open and close animations. Requires JS for close-state cleanup.
See also: component-patterns.md § Popovers and dropdowns for Radix UI transform-origin and scale patterns.
<div class="t-dropdown" data-origin="top-left">
Menu content
</div>.t-dropdown {
opacity: 0;
transform: scale(var(--dropdown-pre-scale));
transition: opacity var(--dropdown-open-dur) var(--dropdown-ease),
transform var(--dropdown-open-dur) var(--dropdown-ease);
}
.t-dropdown.is-open {
opacity: 1;
transform: scale(1);
}
.t-dropdown.is-closing {
opacity: 0;
transform: scale(0.99);
transition-duration: var(--dropdown-close-dur);
}
.t-dropdown[data-origin="top-left"] { transform-origin: top left; }
.t-dropdown[data-origin="top-center"] { transform-origin: top center; }
.t-dropdown[data-origin="top-right"] { transform-origin: top right; }
.t-dropdown[data-origin="bottom-left"] { transform-origin: bottom left; }
.t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; }
.t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; }
@media (prefers-reduced-motion: reduce) {
.t-dropdown { transition: none; }
}JS, close with cleanup:
function closeDropdown(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--dropdown-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}---
Modal dialog
Scale-up modal with softer scale-down on close. Class-based state management.
See also: component-patterns.md § Modals and dialogs for @starting-style entry pattern.
<div class="t-modal" role="dialog">Modal content</div>.t-modal {
opacity: 0;
transform: scale(var(--modal-scale));
transform-origin: center;
transition: opacity var(--modal-open-dur) var(--modal-ease),
transform var(--modal-open-dur) var(--modal-ease);
}
.t-modal.is-open {
opacity: 1;
transform: scale(1);
}
.t-modal.is-closing {
opacity: 0;
transform: scale(var(--modal-scale));
transition-duration: var(--modal-close-dur);
}
@media (prefers-reduced-motion: reduce) {
.t-modal { transition: none; }
}JS, close with cleanup:
function closeModal(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--modal-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}---
Text state swap
Swap text in place with a blurred vertical transition ("Processing..." → "Done"). Requires JS to coordinate the three-phase sequence.
<span class="t-text-swap">Processing...</span>.t-text-swap {
display: inline-block;
transition: opacity var(--text-swap-dur) var(--text-swap-ease),
transform var(--text-swap-dur) var(--text-swap-ease),
filter var(--text-swap-dur) var(--text-swap-ease);
}
.t-text-swap.is-exit {
opacity: 0;
transform: translateY(calc(-1 * var(--text-swap-y)));
filter: blur(var(--text-swap-blur));
}
.t-text-swap.is-enter-start {
opacity: 0;
transform: translateY(var(--text-swap-y));
filter: blur(var(--text-swap-blur));
}
@media (prefers-reduced-motion: reduce) {
.t-text-swap { transition: none; }
}JS, three-phase orchestration:
function swapText(el, newText) {
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--text-swap-dur"));
el.classList.add("is-exit");
setTimeout(() => {
el.textContent = newText;
el.classList.remove("is-exit");
el.classList.add("is-enter-start");
void el.offsetWidth; // force reflow
el.classList.remove("is-enter-start");
}, dur);
}---
Page side-by-side slides
Slide between two adjacent pages (list/detail, wizard steps). Page 1 exits left, page 2 enters right.
See also: component-patterns.md § Step form navigation for the Motion/AnimatePresence approach with direction variants.
<div class="t-page-slide" data-page="1">
<section data-page-id="1">Page 1</section>
<section data-page-id="2">Page 2</section>
</div>.t-page-slide {
display: grid;
overflow: hidden;
}
.t-page-slide > * {
grid-area: 1 / 1;
transition: opacity var(--page-dur) var(--page-ease),
transform var(--page-dur) var(--page-ease),
filter var(--page-dur) var(--page-ease);
}
/* Page 1 active */
.t-page-slide[data-page="1"] [data-page-id="1"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="1"] [data-page-id="2"] {
opacity: 0;
transform: translateX(var(--page-dist));
filter: blur(var(--page-blur));
}
/* Page 2 active */
.t-page-slide[data-page="2"] [data-page-id="2"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="2"] [data-page-id="1"] {
opacity: 0;
transform: translateX(calc(-1 * var(--page-dist)));
filter: blur(var(--page-blur));
}
@media (prefers-reduced-motion: reduce) {
.t-page-slide > * { transition: none; }
}JS, switch page:
slider.setAttribute("data-page", String(n));Set --page-exit-enabled: 0 for fade-only without sliding (useful during initial load).
---
Number pop-in
Re-enter digits with directional blur when a number updates (counters, prices, balances). Each digit animates individually with optional stagger.
<span class="t-digits">
<span class="t-digit">1</span>
<span class="t-digit">2</span>
<span class="t-digit" data-stagger="1">3</span>
<span class="t-digit" data-stagger="2">4</span>
</span>.t-digits {
display: inline-flex;
}
@keyframes digit-enter {
from {
opacity: 0;
transform: translate(
calc(var(--digit-dir-x) * var(--digit-dist)),
calc(var(--digit-dir-y) * var(--digit-dist))
);
filter: blur(var(--digit-blur));
}
}
.t-digit {
display: inline-block;
animation: digit-enter var(--digit-dur) var(--digit-ease) both;
}
.t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); }
.t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); }
@media (prefers-reduced-motion: reduce) {
.t-digit { animation: none; }
}JS, replay on update:
function updateDigits(container, newValue) {
container.classList.remove("is-animating");
container.innerHTML = String(newValue)
.split("")
.map((d, i, arr) => {
const stagger = i >= arr.length - 2 ? ` data-stagger="${arr.length - 1 - i}"` : "";
return `<span class="t-digit"${stagger}>${d}</span>`;
})
.join("");
void container.offsetWidth; // force reflow
container.classList.add("is-animating");
}---
Avatar group hover
Distance-falloff lift on a horizontal stack of items. The hovered item lifts and scales; neighbors lift with decreasing intensity. Bouncy spring on mouse leave.
<div class="t-avatar-group">
<div class="t-avatar">A</div>
<div class="t-avatar">B</div>
<div class="t-avatar">C</div>
</div>.t-avatar-group {
display: flex;
gap: 4px;
}
.t-avatar {
transition: transform var(--avatar-dur) var(--avatar-ease-in);
}
@media (prefers-reduced-motion: reduce) {
.t-avatar { transition: none; }
}JS, distance-based lift:
Set transition-timing-function inline before writing CSS variables. The browser uses whatever timing function is current when the property changes; this enables smooth ease-in on hover and bouncy ease-out on return without separate declarations.
const group = document.querySelector(".t-avatar-group");
const items = [...group.querySelectorAll(".t-avatar")];
const lift = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-lift"));
const scale = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-scale"));
const falloff = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-falloff"));
group.addEventListener("mouseenter", (e) => {
const target = e.target.closest(".t-avatar");
if (!target) return;
const idx = items.indexOf(target);
items.forEach((item, i) => {
const dist = Math.abs(i - idx);
item.style.transitionTimingFunction = "var(--avatar-ease-in)";
if (dist === 0) {
item.style.transform = `translateY(${lift}px) scale(${scale})`;
} else {
const y = lift * Math.pow(falloff, dist);
item.style.transform = `translateY(${y}px)`;
}
});
}, true);
group.addEventListener("mouseleave", () => {
items.forEach((item) => {
item.style.transitionTimingFunction = "var(--avatar-ease-out)";
item.style.transform = "";
});
});---
Success celebration
Multi-layered success animation combining fade, rotation, blur reduction, Y-axis bob with overshoot, and optional SVG stroke drawing. Toggle data-state to "in".
<div class="t-success" data-state="out">
<svg><path class="t-success-path" d="..." /></svg>
</div>.t-success {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
@keyframes success-in {
0% {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
100% {
opacity: 1;
transform: rotate(var(--success-rotate-to)) translateY(0);
filter: blur(0);
}
}
.t-success[data-state="in"] {
animation: success-in var(--success-opacity-dur) var(--success-ease) forwards;
}
.t-success-path {
stroke-dashoffset: var(--path-length);
stroke-dasharray: var(--path-length);
transition: stroke-dashoffset var(--success-path-dur) var(--success-ease);
transition-delay: var(--success-path-delay);
}
.t-success[data-state="in"] .t-success-path {
stroke-dashoffset: 0;
}
@media (prefers-reduced-motion: reduce) {
.t-success { animation: none; opacity: 1; transform: none; filter: none; }
.t-success-path { transition: none; stroke-dashoffset: 0; }
}JS, set path length and replay:
Never hardcode stroke-dasharray. Use getTotalLength() to measure the actual path.
function playSuccess(el) {
const path = el.querySelector(".t-success-path");
if (path) {
const len = path.getTotalLength();
el.style.setProperty("--path-length", len);
}
el.setAttribute("data-state", "out");
void el.offsetWidth; // force reflow to restart keyframes
el.setAttribute("data-state", "in");
}---
Error state shake
Per-segment shake keyframe with auto-reverting error border. Three classes manage the lifecycle: .is-error on wrapper and input, .is-shaking on input only.
<div class="t-error-wrap">
<input class="t-error-input" />
<p class="t-error-msg">Invalid email</p>
</div>@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(var(--shake-dist)); }
50% { transform: translateX(calc(-1 * var(--shake-overshoot))); }
75% { transform: translateX(calc(var(--shake-dist) * 0.5)); }
100% { transform: translateX(0); }
}
.t-error-input {
transition: border-color var(--shake-revert-dur) ease;
}
.t-error-input.is-error {
border-color: var(--color-error, #ef4444);
}
.t-error-input.is-shaking {
animation: shake
calc(var(--shake-dur-1) + var(--shake-dur-2) + var(--shake-dur-3))
var(--shake-ease);
}
.t-error-msg {
opacity: 0;
transform: translateY(-4px);
transition: opacity 150ms ease, transform 150ms ease;
}
.t-error-wrap.is-error .t-error-msg {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.t-error-input { animation: none; }
.t-error-msg { transition: none; }
}JS, trigger and auto-revert:
Keep .is-error and .is-shaking as separate classes. .is-shaking controls only the shake animation and is removed on animationend. .is-error controls the border colour and message visibility, and auto-reverts after the hold duration.
function triggerError(wrap, input) {
wrap.classList.add("is-error");
input.classList.add("is-error", "is-shaking");
input.addEventListener("animationend", () => {
input.classList.remove("is-shaking");
}, { once: true });
const hold = parseFloat(getComputedStyle(wrap).getPropertyValue("--shake-hold"));
setTimeout(() => {
wrap.classList.remove("is-error");
input.classList.remove("is-error");
}, hold);
}Related skills
How it compares
Pick ui-animation over generic CSS skills when motion needs clipped reveals and sliders rather than simple fade or scale transitions.
FAQ
What properties should be animated?
Use transform and opacity for movement; color and background-color for state feedback; never animate layout properties except deliberate card resize recipes.
CSS transitions or keyframes?
Prefer CSS transitions for interruptible UI; keyframes restart from zero on interruption while transitions retarget smoothly.
How are animations reviewed?
Apply ten standards from review-format.md, output Before After Why table, and end with an explicit Block or Approve verdict.
Is Ui Animation safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.