npx skills add ...
npx skills add vercel-labs/open-agents --skill web-animation-design
npx skills add vercel-labs/open-agents --skill web-animation-design
Design and implement web animations that feel natural and purposeful. Use this skill proactively whenever the user asks questions about animations, motion, easing, timing, duration, springs, transitions, or animation performance. This includes questions about how to animate specific UI elements, which easing to use, animation best practices, or accessibility considerations for motion. Triggers on: easing, ease-out, ease-in, ease-in-out, cubic-bezier, bounce, spring physics, keyframes, transform, opacity, fade, slide, scale, hover effects, microinteractions, Framer Motion, React Spring, GSAP, CSS transitions, entrance/exit animations, page transitions, stagger, will-change, GPU acceleration, prefers-reduced-motion, modal/dropdown/tooltip/popover/drawer animations, gesture animations, drag interactions, button press feel, feels janky, make it smooth.
The same skill content is published under more than one repo. The install counts are split across them; any of these commands works.
A comprehensive guide for creating animations that feel right, based on Emil Kowalski's "Animations on the Web" course.
When this skill is first invoked without a specific question, respond only with:
I'm ready to help you with animations based on Emil Kowalski's animations.dev course.
Do not provide any other information until the user asks a question.
When reviewing animations, you MUST use a markdown table. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this:
| Before | After |
|---|---|
transform: scale(0) | transform: scale(0.95) |
animation: fadeIn 400ms ease-in | animation: fadeIn 200ms ease-out |
| No reduced motion support | @media (prefers-reduced-motion: reduce) {...} |
Wrong format (never do this):
Correct format: A single markdown table with | Before | After | columns, one row per issue.
Every animation decision starts with these questions:
ease-outease-in-outeaseUse for user-initiated interactions: dropdowns, modals, tooltips, any element entering or exiting the screen.
Why it works: Acceleration at the start creates an instant, responsive feeling. The element "jumps" toward its destination then settles in.
Use when elements already on screen need to move or morph. Mimics natural motion like a car accelerating then braking.
Use for hover states and color transitions. The asymmetrical curve (faster start, slower end) feels elegant for gentle animations.
Only use for:
Linear feels robotic and unnatural for interactive elements.
Avoid for UI animations. Makes interfaces feel sluggish because the slow start delays visual feedback.
Elements that animate together must use the same easing and duration. Modal + overlay, tooltip + arrow, drawer + backdrop—if they move as a unit, they should feel like a unit.
| Element Type | Duration |
|---|---|
| Micro-interactions | 100-150ms |
| Standard UI (tooltips, dropdowns) | 150-250ms |
| Modals, drawers | 200-300ms |
Rules:
Determine how often users will see the animation:
Example: Raycast never animates because users open it hundreds of times a day.
Do animate:
Don't animate:
Marketing vs. Product:
Springs feel more natural because they don't have fixed durations—they simulate real physics.
Apple's approach (recommended):
Traditional physics:
Springs maintain velocity when interrupted—CSS animations restart from zero. This makes springs ideal for gestures users might change mid-motion.
Only animate transform and opacity. These skip layout and paint stages, running entirely on the GPU.
Avoid animating:
padding, margin, height, width (trigger layout)blur filters above 20px (expensive, especially Safari)React-specific:
Framer Motion:
requestAnimationFrameAnimations can cause motion sickness or distraction for some users.
Whenever you add an animation, also add a media query to disable it:
prefers-reduced-motion media queryanimation: none or transition: none (no !important)Touch devices trigger hover on tap, causing false positives.
Quick reference for common scenarios. See PRACTICAL-TIPS.md for detailed implementations.
| Scenario | Solution |
|---|---|
| Make buttons feel responsive | Add transform: scale(0.97) on :active |
| Element appears from nowhere | Start from scale(0.95), not scale(0) |
| Shaky/jittery animations | Add will-change: transform |
| Hover causes flicker | Animate child element, not parent |
| Popover scales from wrong point | Set transform-origin to trigger location |
| Sequential tooltips feel slow | Skip delay/animation after first tooltip |
| Small buttons hard to tap | Use 44px minimum hit area (pseudo-element) |
| Something still feels off | Add subtle blur (under 20px) to mask it |
| Hover triggers on mobile | Use @media (hover: hover) and (pointer: fine) |
Is the element entering or exiting the viewport? ├── Yes → ease-out └── No ├── Is it moving/morphing on screen? │ └── Yes → ease-in-out └── Is it a hover change? ├── Yes → ease └── Is it constant motion? ├── Yes → linear └── Default → ease-out
/* Sorted weak to strong */
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);transition: background-color 150ms ease;/* Both use the same timing */
.modal {
transition: transform 200ms ease-out;
}
.overlay {
transition: opacity 200ms ease-out;
}// Duration + bounce (easier to understand)
{ type: "spring", duration: 0.5, bounce: 0.2 }// Mass, stiffness, damping (more complex)
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }/* Force GPU acceleration */
.animated-element {
will-change: transform;
}// Hardware accelerated (transform as string)
<motion.div animate={{ transform: "translateX(100px)" }} />
// NOT hardware accelerated (more readable)
<motion.div animate={{ x: 100 }} />.modal {
animation: fadeIn 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal {
animation: none;
}
}import { useReducedMotion } from "framer-motion";
function Component() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
/>
);
}/* Disable hover animations on touch devices */
@media (hover: hover) and (pointer: fine) {
.element:hover {
transform: scale(1.05);
}
}