Install
openclaw skills install web-animation-designDesign 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.
openclaw skills install web-animation-designThis skill is part of the creative pack. Use when the task explicitly involves animation, motion, or interaction feel. Do NOT load this for general UI building — the motion reference in design-review covers baseline motion quality.
Use when: user asks about animations, easing, springs, transitions, interaction feel, or "make it smooth." Skip when: building standard UI where the motion reference in design-review is sufficient.
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):
Before: transform: scale(0)
After: transform: scale(0.95)
────────────────────────────
Before: 400ms duration
After: 200ms
Correct format: A single markdown table with | Before | After | columns, one row per issue.
Does this involve layout changes, shared transitions, or exit animations in React?
├── Yes → Framer Motion (layout animations, AnimatePresence, layoutId)
│ Import from "motion/react" (NOT "framer-motion")
└── No
├── Is it a simple enter/exit or hover? → CSS transitions/keyframes
├── Is it performance-critical (heavy page, many elements)? → CSS (hardware-accelerated)
├── Does it need spring physics or interruptibility? → Framer Motion
├── Does it need gesture tracking (drag, cursor follow)? → FM motion values
└── Is it a constant-speed loop? → CSS keyframes
Always check prefers-reduced-motion. No exceptions.
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.
/* Sorted weak to strong */
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
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.
/* 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);
Use for hover states and color transitions. The asymmetrical curve (faster start, slower end) feels elegant for gentle animations.
transition: background-color 150ms ease;
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.
/* Both use the same timing */
.modal {
transition: transform 200ms ease-out;
}
.overlay {
transition: opacity 200ms ease-out;
}
| 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):
// Duration + bounce (easier to understand)
{ type: "spring", duration: 0.5, bounce: 0.2 }
Traditional physics:
// Mass, stiffness, damping (more complex)
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
Springs maintain velocity when interrupted—CSS animations restart from zero. This makes springs ideal for gestures users might change mid-motion.
The most powerful FM feature. Lets you animate properties CSS can't: flex-direction, justify-content, position changes.
layout PropAdd layout to any motion.* element to auto-animate layout changes:
<motion.div layout className="element" />
When this element's size or position changes (due to state, content, siblings), FM animates it smoothly. No manual measurement needed.
layoutId)Connect two separate elements so one morphs into the other:
// Tab highlight — only rendered for active tab
{activeTab === tab ? (
<motion.div layoutId="tab-indicator" className="highlight" />
) : null}
Use cases: tab highlights, card → modal expansion, button → popover morph, trash interaction (images move between containers).
Creative trick: layoutId creates illusions. The feedback popover's "placeholder" is actually a <span> with a shared layoutId — not a real textarea placeholder. It morphs from button text to popover text.
FM can't animate auto to auto. Use react-use-measure:
import useMeasure from "react-use-measure";
const [ref, bounds] = useMeasure();
<motion.div animate={{ height: bounds.height }}>
<div ref={ref}>{dynamicContent}</div>
</motion.div>
"sync" (default) — enter and exit play simultaneously"wait" — exit completes before enter starts (copy/check icon swap)"popLayout" — removes exiting element from layout flow immediately (often the right choice for morphing UIs)Use the custom prop to pass dynamic data to exiting components (whose state is stale):
<AnimatePresence mode="popLayout" custom={direction}>
<motion.div
key={step}
custom={direction}
initial="enter"
animate="center"
exit="exit"
variants={{
enter: (dir) => ({ x: dir > 0 ? 100 : -100, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir) => ({ x: dir > 0 ? -100 : 100, opacity: 0 }),
}}
/>
</AnimatePresence>
key prop on animated elements inside AnimatePresenceinitial={false} to skip mount animation (icon swaps, button states)Motion values update outside React's render cycle — no re-renders, 60fps.
useMotionValue — Instant updates (gestures)const x = useMotionValue(0);
// Update via x.set(newValue), read via x.get()
<motion.div style={{ x }} />
Use for: direct gesture tracking (drag distance → scale), any 1:1 mapping where spring lag would feel disconnected.
useSpring — Animated updates (follow-behind)const x = useSpring(0, { mass: 0.1, damping: 16, stiffness: 71 });
// x.set(newValue) animates to it with spring physics
Use for: cursor followers, momentum effects, anything that should trail behind input.
useTransform — Map one value to another// Range mapping: y position [0, 300] → scale [1, 1.5]
const scale = useTransform(y, [0, 300], [1, 1.5]);
// Function form: format a value
const display = useTransform(angle, v => `${Math.round(v)}°`);
Use for: scroll-linked effects, cursor-distance effects, value formatting.
useMotionTemplate — String interpolation with motion valuesconst clipPath = useMotionTemplate`inset(0px ${percent}% 0px 0px)`;
<motion.div style={{ clipPath }} />
MotionConfig — Default transitions for a subtree<MotionConfig transition={{ type: "spring", duration: 0.5, bounce: 0.2 }}>
{/* All children use this transition unless overridden */}
</MotionConfig>
Staggered entrance animations create a wave effect. Trial and error until it feels right — no formula.
.item { animation: fadeSlideIn 400ms ease-out both; }
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }
/* etc. */
const container = {
animate: { transition: { staggerChildren: 0.05 } }
};
const item = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 }
};
Rules:
Animation timing IS brand identity:
ease curve (not ease-out) for elegance.The north star: nothing "appears" or "disappears" — everything morphs. Family (iOS) is the gold standard.
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)/* Force GPU acceleration */
.animated-element {
will-change: transform;
}
React-specific:
Framer Motion:
// Hardware accelerated (transform as string)
<motion.div animate={{ transform: "translateX(100px)" }} />
// NOT hardware accelerated (more readable)
<motion.div animate={{ x: 100 }} />
requestAnimationFrameCSS variables are inheritable — changing one causes style recalc for ALL children. In deep component trees (20+ items), this kills drag/scroll performance.
// BAD — recalculates all children
const style = { "--swipe-amount": `${distance}px` };
// GOOD — updates only this element
const style = { transform: `translateY(${distance}px)` };
GPU/CPU handoff can cause 1px shift at animation start/end:
.element { will-change: transform; }
Animations can cause motion sickness or distraction for some users.
Whenever you add an animation, also add a media query to disable it:
.modal {
animation: fadeIn 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.modal {
animation: none;
}
}
Reduced motion ≠ no motion. Animations help users understand UI. Removing all motion hurts comprehension.
animation-delay: -0.4sOption 1: Per-component hook
import { useReducedMotion } from "motion/react";
function Component() {
const shouldReduceMotion = useReducedMotion();
const closedX = shouldReduceMotion ? 0 : "-100%";
return (
<motion.div animate={{
opacity: isOpen ? 1 : 0,
x: isOpen ? 0 : closedX
}} />
);
}
Option 2: App-wide wrapper (recommended)
import { MotionConfig } from "motion/react";
// Wraps your entire app — FM auto-reduces to opacity/backgroundColor only
<MotionConfig reducedMotion="user">{children}</MotionConfig>
Note: default is "never" — you must set this yourself.
/* Disable hover animations on touch devices */
@media (hover: hover) and (pointer: fine) {
.element:hover {
transform: scale(1.05);
}
}
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