
Emilkowal Animations
- 1.9k installs
- 186 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
emilkowal-animations is an agent skill that Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in .
About
Comprehensive animation guide for web interfaces based on Emil Kowalski s teachings open source libraries Sonner Vaul and his animations dev https animations dev course Contains 58 rules across 8 categories prioritized by impact Reference these guidelines when Adding animations to React components Choosing easing curves or timing values Implementing gesture based interactions swipe drag Building toast notifications or drawer components Optimizing animation performance Ensuring animation accessibility Expressing any of the above with Tailwind CSS v4 utilities see the tw category Priority Category Impact Prefix 1 Easing Selection CRITICAL ease 2 Timing Duration CRITICAL timing 3 Property Selection HIGH props 4 Transform Techniques HIGH transform 5 Interaction Patterns MEDIUM HIGH interact 6 Strategic Animation MEDIUM strategy 7 Accessibility Polish MEDIUM polish 8 Tailwind v4 Utilities MEDIUM tw ease out default references ease out default md Use ease out as your default easing ease custom curves references ease custom curves md Use custom cubic bezier over built in CSS ease in out onscreen references ease in out onscreen md Use ease
- description: Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing a
- Comprehensive animation guide for web interfaces based on Emil Kowalski's teachings, open-source libraries (Sonner, Vaul
- - Adding animations to React components
- Follow emilkowal-animations SKILL.md steps and documented constraints.
- Follow emilkowal-animations SKILL.md steps and documented constraints.
Emilkowal Animations by the numbers
- 1,949 all-time installs (skills.sh)
- +48 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #622 of 16,565 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
emilkowal-animations capabilities & compatibility
- Capabilities
- description: emil kowalski's animation best prac · comprehensive animation guide for web interfaces · adding animations to react components · follow emilkowal animations skill.md steps and d
- Use cases
- orchestration
What emilkowal-animations says it does
description: Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in React, CSS, or Framer Motion. Triggers on tasks involving transitio
Comprehensive animation guide for web interfaces based on Emil Kowalski's teachings, open-source libraries (Sonner, Vaul), and his [animations.dev](https://animations.dev) course. Contains 58 rules ac
- Adding animations to React components
npx skills add https://github.com/pproenca/dot-skills --skill emilkowal-animationsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| repo stars | ★ 186 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
When should an agent use emilkowal-animations and what problem does it solve?
Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in React, CSS, or Framer Motion. Triggers on tasks involving transitions, easing, g
Who is it for?
Developers invoking emilkowal-animations as documented in the skill source.
Skip if: Skip when requirements fall outside emilkowal-animations documented scope.
When should I use this skill?
Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in React, CSS, or Framer Motion. Triggers on tasks involving transitions, easing, g
What you get
Outputs aligned with the emilkowal-animations SKILL.md workflow and stated deliverables.
- Animation rule-guided code
- Review feedback against 43 rules
By the numbers
- Contains 43 rules across 7 impact-ranked categories
- Each rule includes incorrect and correct code example pairs
Files
Emil Kowalski Animation Best Practices
Comprehensive animation guide for web interfaces based on Emil Kowalski's teachings, open-source libraries (Sonner, Vaul), and his animations.dev course. Contains 58 rules across 8 categories, prioritized by impact.
When to Apply
Reference these guidelines when:
- Adding animations to React components
- Choosing easing curves or timing values
- Implementing gesture-based interactions (swipe, drag)
- Building toast notifications or drawer components
- Optimizing animation performance
- Ensuring animation accessibility
- Expressing any of the above with Tailwind CSS v4 utilities (see the
tw-category)
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Easing Selection | CRITICAL | ease- |
| 2 | Timing & Duration | CRITICAL | timing- |
| 3 | Property Selection | HIGH | props- |
| 4 | Transform Techniques | HIGH | transform- |
| 5 | Interaction Patterns | MEDIUM-HIGH | interact- |
| 6 | Strategic Animation | MEDIUM | strategy- |
| 7 | Accessibility & Polish | MEDIUM | polish- |
| 8 | Tailwind v4 Utilities | MEDIUM | tw- |
Quick Reference
1. Easing Selection (CRITICAL)
- `ease-out-default` - Use ease-out as your default easing
- `ease-custom-curves` - Use custom cubic-bezier over built-in CSS
- `ease-in-out-onscreen` - Use ease-in-out for on-screen movement
- `ease-spring-natural` - Use spring animations for natural motion
- `ease-spring-config` - Configure springs with duration and bounce
- `ease-ios-drawer` - Use iOS-style easing for drawer components
- `ease-context-matters` - Match easing to animation context
2. Timing & Duration (CRITICAL)
- `timing-300ms-max` - Keep UI animations under 300ms
- `timing-faster-better` - Faster animations improve perceived performance
- `timing-asymmetric` - Use asymmetric timing for press and release
- `timing-tooltip-delay` - Delay initial tooltips, instant subsequent ones
- `timing-drawer-500ms` - Use 500ms duration for drawer animations
3. Property Selection (HIGH)
- `props-transform-opacity` - Animate only transform and opacity
- `props-hardware-accelerated` - Use hardware-accelerated animations when main thread is busy
- `props-will-change` - Use will-change to prevent 1px shift
- `props-avoid-css-variables` - Avoid CSS variables for drag animations
- `props-clip-path-performant` - Use clip-path for layout-free reveals
4. Transform Techniques (HIGH)
- `transform-scale-097` - Scale buttons to 0.97 on press
- `transform-never-scale-zero` - Never animate from scale(0)
- `transform-percentage-translate` - Use percentage values for translateY
- `transform-origin-aware` - Make animations origin-aware
- `transform-scale-children` - Scale transforms affect children
- `transform-3d-preserve` - Use preserve-3d for 3D transform effects
- `transform-starting-style` - Animate enter states with @starting-style
5. Interaction Patterns (MEDIUM-HIGH)
- `interact-interruptible` - Make animations interruptible
- `interact-momentum-dismiss` - Use momentum-based dismissal
- `interact-damping` - Damp drag at boundaries
- `interact-scroll-drag-conflict` - Resolve scroll and drag conflicts
- `interact-snap-points` - Implement velocity-aware snap points
- `interact-friction-upward` - Allow upward drag with friction
- `interact-pointer-capture` - Use pointer capture for drag operations
- `interact-multitouch` - Ignore extra touch points during drag
- `interact-touch-hover` - Gate hover animations behind a pointer media query
6. Strategic Animation (MEDIUM)
- `strategy-keyboard-no-animate` - Never animate keyboard-initiated actions
- `strategy-frequency-matters` - Consider interaction frequency before animating
- `strategy-purpose-required` - Every animation must have a purpose
- `strategy-feedback-immediate` - Provide immediate feedback on all actions
- `strategy-marketing-exception` - Marketing sites are the exception
- `strategy-cohesion` - Match motion to the component's personality
- `strategy-review-fresh-eyes` - Review animations in slow motion and the next day
7. Accessibility & Polish (MEDIUM)
- `polish-reduced-motion` - Respect prefers-reduced-motion
- `polish-opacity-fallback` - Use opacity as reduced motion fallback
- `polish-framer-hook` - Use useReducedMotion hook in Framer Motion
- `polish-dont-remove-all` - Keep gentle animation for reduced motion
- `polish-blur-bridge` - Use blur to bridge animation states
- `polish-clip-path-tabs` - Use clip-path for tab transitions
- `polish-toast-stacking` - Implement toast stacking with scale and offset
- `polish-scroll-reveal` - Trigger scroll animations at appropriate threshold
- `polish-hover-gap-fill` - Fill gaps between hoverable elements
- `polish-stagger-children` - Stagger child animations for orchestration
8. Tailwind v4 Utilities (MEDIUM)
Express the principles above with proper Tailwind CSS v4 utilities. Applies only to Tailwind v4 projects; the raw-CSS and Framer Motion rules above remain the source of truth.
- `tw-ease-duration-defaults` - Set ease-out and a sub-300ms duration explicitly
- `tw-custom-easing-theme` - Register custom easing curves as @theme tokens
- `tw-press-scale` - Use active:scale-[0.97] for button press feedback
- `tw-asymmetric-timing` - Split press and release timing with active:duration
- `tw-starting-enter` - Animate enter states with the starting: variant
- `tw-reduced-motion` - Gate movement behind motion-safe / motion-reduce
- `tw-origin-aware` - Set transform-origin with origin-* utilities
- `tw-will-change` - Scope will-change-transform to the active gesture
Key Values Reference
| Value | Usage |
|---|---|
cubic-bezier(0.23, 1, 0.32, 1) | Strong ease-out for UI interactions |
cubic-bezier(0.77, 0, 0.175, 1) | Strong ease-in-out for on-screen movement |
cubic-bezier(0.32, 0.72, 0, 1) | iOS-style drawer/sheet animation |
scale(0.97) | Button press feedback |
scale(0.95) | Minimum enter scale (never scale(0)) |
200ms ease-out | Standard UI transition |
300ms | Maximum duration for UI animations |
500ms | Drawer animation duration |
0.11 px/ms | Velocity threshold for momentum dismiss |
100px | Scroll-reveal viewport threshold |
14px | Toast stack offset |
Duration by Element
Pick duration by how often the element is seen and how much it moves. Keep UI animations under 300ms.
| Element | Duration |
|---|---|
| Button press feedback | 100–160ms |
| Tooltips, small popovers | 125–200ms |
| Dropdowns, selects | 150–250ms |
| Modals, drawers | 200–500ms |
| Marketing / explanatory | Can be longer |
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on animation feel and user experience implications.}
Incorrect ({what's wrong}):
```{css|tsx|typescript} {Bad code example - production-realistic, not strawman} {/ Comments explaining the problem /}
**Correct ({what's right}):**
{Good code example - minimal diff from incorrect} {/ Comments explaining the benefit /}
{Optional sections as needed:}
**Alternative ({context}):**
{Alternative approach when applicable}
**When NOT to use this pattern:**
- {Exception 1}
- {Exception 2}
Reference: [{Reference Title}]({Reference URL from emilkowal.ski, animations.dev, or Emil's GitHub})
{
"name": "emilkowal-animations",
"version": "1.2.0",
"organization": "Emil Kowalski",
"technology": "Web Animations",
"date": "May 2026",
"abstract": "Comprehensive animation guide for web interfaces based on Emil Kowalski's teachings, open-source libraries (Sonner, Vaul), his animations.dev course, and his official emil-design-eng skill. Contains 58 rules across 8 categories, prioritized by impact from critical (easing selection, timing) to polish (blur bridging, stagger orchestration), plus a Tailwind CSS v4 category that maps each principle to the proper v4 utilities (@theme easing tokens, the starting: variant, active:/motion-reduce: variants, transform-origin and will-change utilities). Each rule includes detailed explanations, production-realistic code examples comparing incorrect vs. correct implementations, and specific values to guide animation decisions.",
"references": [
"https://emilkowal.ski/ui/great-animations",
"https://emilkowal.ski/ui/good-vs-great-animations",
"https://emilkowal.ski/ui/7-practical-animation-tips",
"https://emilkowal.ski/ui/css-transforms",
"https://emilkowal.ski/ui/the-magic-of-clip-path",
"https://emilkowal.ski/ui/building-a-drawer-component",
"https://emilkowal.ski/ui/building-a-toast-component",
"https://emilkowal.ski/ui/building-a-hold-to-delete-component",
"https://emilkowal.ski/ui/you-dont-need-animations",
"https://animations.dev/",
"https://github.com/emilkowalski/vaul",
"https://github.com/emilkowalski/sonner",
"https://github.com/emilkowalski/skill",
"https://tailwindcss.com/blog/tailwindcss-v4",
"https://tailwindcss.com/docs/upgrade-guide"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Easing Selection (ease)
Impact: CRITICAL Description: Easing is the most important part of any animation—it can make a bad animation feel great and vice versa. Wrong easing choice propagates poor feel throughout the entire interaction.
2. Timing & Duration (timing)
Impact: CRITICAL Description: Animations over 300ms feel slow and disconnected from user actions. Duration directly affects perceived performance and interface responsiveness.
3. Property Selection (props)
Impact: HIGH Description: Animating transform and opacity triggers only the composite rendering step; animating layout properties causes expensive reflows and visual jank.
4. Transform Techniques (transform)
Impact: HIGH Description: CSS transforms are the foundation of most web animations. Proper scale, translate, rotate, and transform-origin usage defines animation quality.
5. Interaction Patterns (interact)
Impact: MEDIUM-HIGH Description: Momentum-based gestures, interruptibility, and responsive feedback make interfaces feel alive and connected to user actions.
6. Strategic Animation (strategy)
Impact: MEDIUM Description: Knowing when NOT to animate is as important as knowing how. Over-animation and animating high-frequency actions destroys user experience.
7. Accessibility & Polish (polish)
Impact: MEDIUM Description: Respecting prefers-reduced-motion, providing safe fallbacks, blur bridging, clip-path reveals, and staggered orchestration elevate good animations to great ones.
8. Tailwind v4 Utilities (tw)
Impact: MEDIUM Description: How to express the principles above with proper Tailwind CSS v4 utilities when a project uses Tailwind, without losing the intended feel. Covers the v4-specific idioms—@theme easing tokens, the starting: variant, active:/motion-reduce: variants, transform-origin and will-change utilities—rather than reaching for transition-all or inline arbitrary curves. Applies only to Tailwind v4 projects; the raw-CSS and Framer Motion rules remain the source of truth.
Match Easing to Animation Context
Different animation contexts require different easing approaches. Using the wrong easing for a context makes animations feel off even when technically smooth.
Easing by Context:
| Context | Recommended Easing | Why |
|---|---|---|
| Enter/Exit | ease-out | Immediate response, smooth settle |
| On-screen movement | ease-in-out | Natural acceleration/deceleration |
| Hover effects | ease (built-in OK) | Simple, quick feedback |
| Spring interactions | spring physics | Natural, interruptible feel |
| Exit only | ease-in | Accelerates away from view |
Incorrect (ease-in for enter animation):
.modal-enter {
animation: slideIn 200ms ease-in;
}
/* Slow start feels unresponsive to user action */Correct (ease-out for enter animation):
.modal-enter {
animation: slideIn 200ms ease-out;
}
/* Fast start responds to user action immediately */Reference: animations.dev
Use Custom Easing Curves Over Built-in CSS
Built-in CSS easing curves (ease, ease-in, ease-out) are usually not strong enough. Custom cubic-bezier curves feel more energetic and polished.
Incorrect (weak built-in curve):
.dropdown {
transition: transform 200ms ease-out;
}
/* Feels generic and muted */Correct (custom curve with more character):
.dropdown {
transition: transform 200ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Feels energetic and intentional */Strong curves to start from:
:root {
/* Strong ease-out for UI interactions */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Strong ease-in-out for on-screen movement */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* iOS-like drawer/sheet curve (from Ionic) */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}Exception: The ease keyword works well for basic hover effects like background color changes—anything more complex needs a custom curve.
Resources for custom curves:
Reference: Good vs Great Animations
Use ease-in-out for On-Screen Movement
For elements already visible that move from one position to another, ease-in-out creates natural motion by accelerating at the start and decelerating at the end—like a vehicle.
Incorrect (ease-out for positional change):
.slider-thumb {
transition: left 300ms ease-out;
}
/* Starts too fast, feels jarring for on-screen movement */Correct (ease-in-out for smooth repositioning):
.slider-thumb {
transition: transform 300ms ease-in-out;
}
/* Accelerates naturally, then settles into place */When to use ease-in-out:
- Carousel slides
- Tab indicator movement
- Drag-and-drop repositioning
- Any element moving across the screen
Reference: Good vs Great Animations
Use iOS-Style Easing for Drawer Components
Drawer components should match the native iOS Sheet animation feel. The curve cubic-bezier(0.32, 0.72, 0, 1) with 500ms duration closely matches iOS behavior.
Incorrect (generic easing):
.drawer {
transition: transform 300ms ease-out;
}
/* Feels like a web component, not native */Correct (iOS-matched curve):
.drawer {
transition: transform 500ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Matches iOS Sheet animation, feels native */This specific curve comes from the Ionic Framework and is used in Vaul, Emil's drawer component library.
Reference: Building a Drawer Component
Use ease-out as Your Default Easing
The ease-out curve starts fast and slows at the end, creating an impression of quick response while maintaining smooth transitions. This mimics how objects naturally decelerate.
Incorrect (linear easing feels robotic):
.modal {
transition: opacity 200ms linear, transform 200ms linear;
}
/* Animation feels mechanical and disconnected */Correct (ease-out feels responsive):
.modal {
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
/* Starts fast, giving immediate feedback, then settles smoothly */When to use ease-out:
- Enter and exit animations
- User-initiated interactions (dropdowns, modals, tooltips)
- Any element responding to user action
Never use `ease-in` for UI. It starts slow, delaying the exact moment the user is watching most closely, so the interface feels sluggish. A dropdown with ease-in at 300ms feels slower than ease-out at the same 300ms. ease-in is the more common mistake than linear—reach for ease-out (or a custom curve) instead.
Reference: Great Animations
Configure Springs with Duration and Bounce
Apple's duration + bounce model is far easier to reason about than raw stiffness/damping/mass. Keep bounce subtle (0.1–0.3) for UI, and reserve visible bounce for drag-to-dismiss or playful moments. Springs also preserve velocity when interrupted, while CSS transitions and keyframes restart from zero—so a spring reverses smoothly mid-gesture.
Incorrect (opaque physics params, overshoots):
<motion.div
animate={{ y: 0 }}
transition={{ type: 'spring', stiffness: 700, damping: 15 }}
/>
// stiffness/damping are hard to tune by intuition; this overshoots wildlyCorrect (duration + bounce, easy to reason about):
<motion.div
animate={{ y: 0 }}
transition={{ type: 'spring', duration: 0.5, bounce: 0.2 }}
/>
// bounce stays subtle; if the user grabs it mid-animation, velocity carries overAlternative (need fine physical control): Use { type: 'spring', mass: 1, stiffness: 100, damping: 10 } only when you need precise physical behaviour the duration/bounce model can't express.
When NOT to use this pattern:
- Speed-critical functional UI (data entry, banking flows) where a fixed-duration ease-out is faster and calmer
- Any animation a user triggers hundreds of times a day
Reference: Great Animations
Use Spring Animations for Natural Motion
Nothing in the real world moves with perfect easing curves. Spring animations create organic, lifelike movement that makes interfaces feel more connected to reality.
Incorrect (instant value update):
function Counter({ value }) {
return <span>{value}</span>
}
// Value jumps instantly, feels artificialCorrect (spring-interpolated value):
import { useSpring, motion } from 'framer-motion'
function Counter({ value }) {
const spring = useSpring(value, { stiffness: 100, damping: 30 })
return <motion.span>{spring}</motion.span>
}
// Value animates with spring physics, feels naturalWhen NOT to use springs:
- Functional interfaces where speed matters (banking apps, data entry)
- High-frequency interactions the user performs hundreds of times daily
Reference: Good vs Great Animations
Damp Drag at Boundaries
When users drag past boundaries, apply resistance (damping) instead of hard stops. The more they drag, the less the element moves—like stretching a rubber band.
Incorrect (hard stop at boundary):
const onDrag = (y) => {
const clampedY = Math.max(0, y) // Hard stop at 0
setPosition(clampedY)
}
// Dragging up at top does nothing, feels brokenCorrect (damped resistance):
const onDrag = (y) => {
if (y < 0) {
// Apply resistance when dragging past boundary
const damped = y * 0.3 // 70% resistance
setPosition(damped)
} else {
setPosition(y)
}
}
// Dragging past boundary has resistance, feels naturalThis creates the "rubber band" effect users expect from native mobile interfaces.
Reference: Building a Drawer Component
Allow Upward Drag with Friction
When users drag in the "wrong" direction (e.g., upward on a swipe-to-dismiss toast), allow movement with increasing friction rather than blocking completely.
Incorrect (hard block):
const onDrag = (y) => {
if (y < 0) return // Block upward drag completely
setDragY(y)
}
// Feels rigid and unnaturalCorrect (friction-based resistance):
const onDrag = (y) => {
if (y < 0) {
// Allow upward drag with friction
const friction = 0.3
setDragY(y * friction)
} else {
setDragY(y)
}
}
// Feels soft and natural, like pushing against resistanceThis is nicer than just stopping the element immediately—it acknowledges the user's input while guiding them.
Reference: Building a Toast Component
Make Animations Interruptible
Users should be able to change animation state at any time with smooth transitions. CSS transitions naturally support interruption; keyframes do not.
Incorrect (keyframes can't be interrupted):
.sidebar {
animation: slideIn 300ms ease-out;
}
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
/* If user closes mid-animation, it jumps or glitches */Correct (transitions retarget smoothly):
.sidebar {
transform: translateX(-100%);
transition: transform 300ms ease-out;
}
.sidebar.open {
transform: translateX(0);
}
/* User can open/close anytime, animation retargets smoothly */Framer Motion also supports interruptible animations natively.
Reference: Great Animations
Use Momentum-Based Dismissal
Allow users to dismiss elements with a fast flick, not just by dragging past a threshold. Calculate velocity and dismiss if either distance OR velocity exceeds threshold.
Incorrect (distance-only threshold):
const onDragEnd = (dragDistance) => {
if (Math.abs(dragDistance) > 100) {
dismiss()
}
}
// Fast flicks don't dismiss if distance is shortCorrect (momentum-based):
const onDragEnd = (dragDistance, dragDuration) => {
const velocity = Math.abs(dragDistance) / dragDuration
if (Math.abs(dragDistance) > 100 || velocity > 0.11) {
dismiss()
}
}
// Fast flicks dismiss even with short distanceThe velocity threshold of ~0.11 pixels/millisecond works well for most swipe-to-dismiss interactions.
Reference: Building a Toast Component
Ignore Extra Touch Points During Drag
Once a drag is in progress, ignore any additional pointers. Without this guard, a second finger touching the screen mid-drag (or switching fingers) starts a new drag and the element snaps to the new pointer position.
Incorrect (every pointer starts a drag):
function onPointerDown(event: React.PointerEvent) {
startDrag(event.clientY)
}
// A second finger begins a fresh drag; the drawer jumps to itCorrect (first pointer wins until released):
function onPointerDown(event: React.PointerEvent) {
if (isDragging) return // ignore extra touch points once dragging
startDrag(event.clientY)
}Pair this with pointer capture so the original finger keeps control even if it leaves the element bounds.
Reference: Building a Drawer Component
Use Pointer Capture for Drag Operations
Use pointer capture during drag operations so the drag continues even when the pointer moves outside the element bounds.
Incorrect (drag breaks at boundary):
const onPointerMove = (e) => {
if (!isDragging) return
updatePosition(e.clientY)
}
// Drag breaks if pointer leaves elementCorrect (pointer capture maintains drag):
const onPointerDown = (e) => {
e.target.setPointerCapture(e.pointerId)
setIsDragging(true)
}
const onPointerMove = (e) => {
if (!isDragging) return
updatePosition(e.clientY)
}
const onPointerUp = (e) => {
e.target.releasePointerCapture(e.pointerId)
setIsDragging(false)
}
// Drag continues even if pointer leaves element boundsThis is essential for swipe-to-dismiss and drag interactions where users naturally overshoot.
Reference: Building a Toast Component
Resolve Scroll and Drag Conflicts
In scrollable containers like drawers, dragging should only start when scrolled to the top. Add a timeout after reaching top to prevent accidental closure from scroll momentum.
Incorrect (drag starts immediately at top):
const shouldDrag = () => {
return scrollContainer.scrollTop === 0
}
// Scroll momentum can accidentally trigger dismissCorrect (timeout prevents momentum accidents):
const [canDrag, setCanDrag] = useState(false)
const timeoutRef = useRef()
const onScroll = () => {
clearTimeout(timeoutRef.current)
if (scrollContainer.scrollTop === 0) {
timeoutRef.current = setTimeout(() => {
setCanDrag(true)
}, 100) // Wait for momentum to settle
} else {
setCanDrag(false)
}
}This matches iOS drawer behavior where you must pause at the top before dragging to close.
Reference: Building a Drawer Component
Implement Velocity-Aware Snap Points
Snap points should respond to velocity—fast flicks can skip intermediate points, while slow drags snap to the closest point.
Incorrect (always snaps to closest):
const onDragEnd = (position) => {
const closest = snapPoints.reduce((a, b) =>
Math.abs(b - position) < Math.abs(a - position) ? b : a
)
animateTo(closest)
}
// Fast flick to close stops at intermediate pointCorrect (velocity allows skipping):
const onDragEnd = (position, velocity) => {
if (velocity > 0.5) {
// Fast flick - snap to point in direction of velocity
const target = velocity > 0 ? snapPoints[snapPoints.length - 1] : snapPoints[0]
animateTo(target)
} else {
// Slow drag - snap to closest
const closest = findClosest(snapPoints, position)
animateTo(closest)
}
}
// Fast flicks can close completely, slow drags snap to nearestReference: Building a Drawer Component
Gate Hover Animations Behind a Pointer Media Query
Touch devices fire :hover on tap and leave it stuck until the user taps elsewhere. Gate hover-only animations behind @media (hover: hover) and (pointer: fine) so they apply only to devices with a real, precise pointer.
Incorrect (hover fires on tap):
.card:hover {
transform: scale(1.05);
}
/* On touch, a tap triggers :hover and the card stays scaled until you tap away */Correct (gated to fine pointers):
@media (hover: hover) and (pointer: fine) {
.card:hover {
transform: scale(1.05);
}
}
/* Mouse users get the hover; touch users get a clean tap */Reference: Great Animations
Use Blur to Bridge Animation States
When easing and timing adjustments don't resolve animation issues, add a subtle blur during the transition. Blur bridges the visual gap between states, masking imperfections.
Incorrect (sharp crossfade):
.button {
transition: background-color 200ms ease-out;
}
/* Hard transition between states */Correct (blur-bridged crossfade):
.button {
transition: background-color 200ms ease-out, filter 200ms ease-out;
}
.button:active {
filter: blur(2px);
}Why it works: Blur tricks the eye into seeing a smooth transition by blending the two states together, rather than seeing two distinct objects.
Use blur sparingly—approximately 2px is usually enough.
Reference: 7 Practical Animation Tips
Use clip-path for Aligned Tab Transitions
Instead of animating a highlight bar separately from text color changes, duplicate the tab list with different styling and use clip-path to reveal the active state.
Incorrect (separate animations can misalign):
.tab-highlight {
transition: left 200ms ease-out;
}
.tab-text {
transition: color 200ms ease-out;
}
/* Timing misalignment visible in slow-motion */Correct (clip-path reveals both simultaneously):
.tabs-wrapper {
position: relative;
}
.tabs-inactive {
color: gray;
}
.tabs-active {
position: absolute;
top: 0;
color: white;
background: blue;
clip-path: inset(0px 75% 0px 0% round 17px);
transition: clip-path 200ms ease-out;
}
/* On tab change, update clip-path to reveal active tab */This keeps the highlight and text in perfect sync because they change as a single unit.
Reference: Animating with clip-path
Keep Gentle Animation for Reduced Motion
Going nuclear and removing all animation hurts usability. Some animations help accessibility—like loading indicators and state change feedback. Provide gentler alternatives instead.
Incorrect (removes everything):
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Loading spinners vanish, state changes are invisible */Correct (thoughtful alternatives):
@media (prefers-reduced-motion: reduce) {
/* Disable spatial movement */
.slide-in {
transform: none;
transition: opacity 200ms ease-out;
}
/* Keep essential feedback animations */
.spinner {
/* Still animates, but with reduced motion */
animation: pulse 1s ease-in-out infinite;
}
.error-shake {
/* Replace shake with color pulse */
animation: error-pulse 200ms ease-out;
}
}Reference: Great Animations
Use useReducedMotion Hook in Framer Motion
Framer Motion provides the useReducedMotion hook for programmatic control over motion preferences. Use it to provide alternative animations.
Incorrect (ignores motion preference):
function Sidebar({ isOpen }) {
return (
<motion.div
animate={{ x: isOpen ? 0 : '-100%' }}
/>
)
}
// Slides regardless of user preferenceCorrect (respects motion preference):
import { useReducedMotion, motion } from 'framer-motion'
function Sidebar({ isOpen }) {
const shouldReduceMotion = useReducedMotion()
const closedX = shouldReduceMotion ? 0 : '-100%'
return (
<motion.div
animate={{
opacity: isOpen ? 1 : 0,
x: isOpen ? 0 : closedX
}}
/>
)
}
// Fades only when motion is reduced, slides otherwiseReference: Great Animations
Fill Gaps Between Hoverable Elements
When hovering should persist across a group of elements with gaps between them, use pseudo-elements to fill the gaps and maintain hover state.
Incorrect (hover drops in gaps):
.toast {
margin-bottom: 8px;
}
.toast:hover {
/* Hover drops when moving between toasts */
}Correct (pseudo-element fills gap):
.toast {
margin-bottom: 8px;
position: relative;
}
.toast::after {
content: '';
position: absolute;
bottom: -8px; /* Fills the gap */
left: 0;
right: 0;
height: 8px;
}
.toast-container:hover .toast {
/* Hover persists when moving between toasts */
}This technique maintains hover state when moving mouse between stacked toasts.
Reference: Building a Toast Component
Use Opacity as Reduced Motion Fallback
Opacity changes don't affect perceived position, size, or shape—they're safe for users with vestibular disorders. Use opacity-only transitions as your reduced motion fallback.
Incorrect (removes all animation):
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
/* No feedback at all, confusing UX */Correct (opacity-only fallback):
.sidebar {
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.sidebar {
transition: opacity 200ms ease-out;
transform: none; /* No movement, only fade */
}
}This provides visual feedback without triggering motion sensitivity.
Reference: Great Animations
Respect prefers-reduced-motion
Animations can cause motion sickness or distract users with attention disorders. Respect the prefers-reduced-motion media query by providing alternative animations.
Incorrect (ignores preference):
.element {
animation: bounce 0.2s ease-out;
}
/* No consideration for motion sensitivity */Correct (respects preference):
.element {
animation: bounce 0.2s ease-out;
}
@media (prefers-reduced-motion: reduce) {
.element {
animation: fade 0.2s ease-out; /* Gentler alternative */
}
}Don't remove all animation—provide safer alternatives that still communicate state changes.
Reference: Great Animations
Trigger Scroll Animations at Appropriate Threshold
Don't trigger scroll-based animations the instant an element enters the viewport. Wait until a meaningful portion (at least 100px) is visible.
Incorrect (triggers at viewport edge):
const { ref, inView } = useInView({ threshold: 0 })
<motion.div
ref={ref}
animate={{ opacity: inView ? 1 : 0 }}
/>
// Animation starts when 1px enters viewportCorrect (triggers when meaningfully visible):
const { ref, inView } = useInView({
threshold: 0,
rootMargin: '-100px 0px' // Must be 100px into viewport
})
<motion.div
ref={ref}
animate={{ opacity: inView ? 1 : 0 }}
transition={{ once: true }} // Only animate once
/>
// Animation starts when 100px is visibleThe once: true option ensures the animation only plays on first visibility.
Reference: Animating with clip-path
Stagger Child Animations for Orchestration
Stagger child animations to create orchestrated reveals. Children should animate sequentially with small delays rather than all at once.
Incorrect (all children animate simultaneously):
<motion.ul animate={{ opacity: 1 }}>
{items.map(item => (
<motion.li animate={{ opacity: 1, y: 0 }}>
{item.name}
</motion.li>
))}
</motion.ul>
// All items appear at onceCorrect (staggered children):
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: {
staggerChildren: 0.05, // 50ms between each child
delayChildren: 0.1 // 100ms before first child
}
}
}
const itemVariants = {
hidden: { opacity: 0, y: 10 },
show: { opacity: 1, y: 0 }
}
<motion.ul variants={container} initial="hidden" animate="show">
{items.map(listItem => (
<motion.li variants={itemVariants}>{listItem.name}</motion.li>
))}
</motion.ul>
// Items cascade in sequenceReference: animations.dev
Implement Toast Stacking with Scale and Offset
Create visual depth in toast notifications by offsetting and scaling each preceding toast. This creates the polished stacking effect seen in Sonner.
Incorrect (flat stacking, no depth):
.toast {
position: absolute;
bottom: calc(var(--index) * 70px);
}
/* Toasts stack flat, no visual hierarchy */Correct (scale + offset creates depth):
.toast {
--lift-amount: 14px;
--toasts-before: 0; /* Set via JS */
position: absolute;
transform:
translateY(calc(var(--lift-amount) * var(--toasts-before) * -1))
scale(calc(1 - (var(--toasts-before) * 0.05)));
}
/* Visual depth: Toast 0 at scale(1), Toast 1 at scale(0.95), etc. */toasts.map((toast, index) => (
<div
key={toast.id}
className="toast"
style={{ '--toasts-before': index }}
/>
))Reference: Building a Toast Component
Avoid CSS Variables for Drag Animations
CSS variables are inherited by all children. During drag animations, updating a CSS variable causes expensive style recalculation for every child element.
Incorrect (CSS variable updates cascade):
function Drawer({ children }) {
const [dragY, setDragY] = useState(0)
return (
<div style={{ '--drag-y': `${dragY}px` }}>
<div style={{ transform: 'translateY(var(--drag-y))' }}>
{children} {/* All children recalculate styles */}
</div>
</div>
)
}Correct (direct style update):
function Drawer({ children }) {
const drawerRef = useRef()
const onDrag = (y) => {
drawerRef.current.style.transform = `translateY(${y}px)`
}
return <div ref={drawerRef}>{children}</div>
}This fix eliminated frame drops in Vaul with 20+ list items.
Reference: Building a Drawer Component
Use clip-path for Layout-Free Reveals
Clip-path creates reveal animations without layout shifts—elements occupy their full space while visually clipped. It's hardware-accelerated and requires no extra DOM elements.
Incorrect (animating height causes layout shift):
.reveal {
height: 0;
overflow: hidden;
transition: height 300ms ease-out;
}
.reveal.open {
height: auto; /* Causes layout recalculation */
}Correct (clip-path, no layout shift):
.reveal {
clip-path: inset(0 0 100% 0); /* Hidden */
transition: clip-path 300ms ease-out;
}
.reveal.open {
clip-path: inset(0 0 0 0); /* Fully revealed */
}Common clip-path patterns:
inset(0 0 100% 0)- Hide bottominset(100% 0 0 0)- Hide topinset(0 100% 0 0)- Hide rightinset(0 0 0 100%)- Hide left
Reference: Animating with clip-path
Use Hardware-Accelerated Animations When Main Thread Is Busy
When the main thread is executing JavaScript, requestAnimationFrame-based animations (like Framer Motion) can become laggy. CSS animations and WAAPI run on the compositor thread, staying smooth regardless.
Incorrect (JavaScript-driven animation):
// Framer Motion during heavy computation
<motion.div animate={{ x: 100 }} />
// Animation may stutter if main thread is blockedCorrect (CSS or WAAPI animation):
// CSS transition (hardware-accelerated)
<div style={{ transform: 'translateX(100px)' }} className="transition-transform" />
// Or WAAPI
element.animate(
[{ transform: 'translateX(0)' }, { transform: 'translateX(100px)' }],
{ duration: 200, easing: 'ease-out' }
)Use Framer Motion for complex orchestration; use CSS/WAAPI for performance-critical animations during heavy computation.
Reference: Great Animations
Animate Only Transform and Opacity
Animating transform and opacity only triggers the composite rendering step—the cheapest operation. Animating layout properties (margin, padding, width, height) triggers expensive layout recalculations.
Incorrect (animates layout property):
.accordion {
transition: height 300ms ease-out, padding 300ms ease-out;
}
/* Triggers layout → paint → composite on every frame */Correct (animates transform only):
.accordion {
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
/* Triggers only composite, GPU-accelerated */Rendering Pipeline: 1. Layout - Calculate positions (expensive) 2. Paint - Draw pixels (expensive) 3. Composite - Combine layers (cheap, GPU)
Transform and opacity skip steps 1 and 2.
Reference: Great Animations
Use will-change to Prevent 1px Shift
If you notice a 1px shift at the end of your animation, use will-change: transform to keep the element on its own compositor layer throughout the animation.
Incorrect (subpixel shift at end):
.card {
transition: transform 200ms ease-out;
}
/* May show 1px shift when animation completes */Correct (stable layer throughout):
.card {
transition: transform 200ms ease-out;
will-change: transform;
}
/* GPU handles animation consistently, no shift */Caution: Don't overuse will-change—it consumes memory. Only apply to elements that will animate frequently.
Reference: @emilkowalski_
Match Motion to the Component's Personality
Animation values should fit the personality of what they animate. A playful product can be bouncier; a professional dashboard should be crisp and fast. Sonner feels satisfying partly because its motion is cohesive—slightly slower and using ease rather than ease-out to read as elegant, matching its design and pacing.
Incorrect (generic curve fights the mood):
.toast {
transition: transform 150ms ease-out; /* crisp, like a dashboard control */
}
/* Reads as abrupt against a relaxed, elegant product */Correct (motion matches the product's mood):
.toast {
transition: transform 400ms ease; /* slightly slower + plain ease = elegant */
}
/* Motion, visual design, and pacing all feel like one coherent system */Reference: Building a Toast Component
Provide Immediate Feedback on All Actions
The interface should feel like it's listening to the user. Every action should have immediate visual feedback—loading states, success confirmations, error indicators.
Incorrect (no feedback during action):
const onSubmit = async () => {
await saveData() // User waits with no feedback
}
<button onClick={onSubmit}>Save</button>
// User wonders if click registeredCorrect (immediate feedback):
const onSubmit = async () => {
setIsLoading(true)
await saveData()
setIsLoading(false)
setShowSuccess(true)
}
<button onClick={onSubmit} disabled={isLoading}>
{isLoading ? <Spinner /> : 'Save'}
</button>
{showSuccess && <CheckAnimation />}
// User knows action is processingFeedback should be instant—even if the actual operation takes time.
Reference: 7 Practical Animation Tips
Consider Interaction Frequency Before Animating
Animations that delight on first use become annoying on the hundredth. Consider how often users will see an animation before adding it.
Frequency Guidelines:
| Frequency | Animation Approach |
|---|---|
| Once (onboarding) | Full, expressive animations OK |
| Daily | Subtle, fast animations |
| Hourly | Very subtle or none |
| Constantly | No animation |
Incorrect (animate frequent action):
// User switches tabs dozens of times per session
<TabContent
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
/>
// Initial delight fades, becomes annoyingCorrect (instant for frequent, animated for rare):
// Frequent: instant
<TabContent style={{ opacity: 1 }} />
// Rare (first visit): animated
{isFirstVisit && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} />
)}Reference: You Don't Need Animations
Never Animate Keyboard-Initiated Actions
Keyboard navigation and actions may be performed hundreds of times daily. Animations on these interactions make the interface feel slow, delayed, and disconnected.
Incorrect (animate keyboard actions):
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
setSelectedIndex(i => i + 1)
}
}
// With animation
<motion.div animate={{ y: selectedIndex * 40 }} />
// Feels slow when pressing arrow keys rapidlyCorrect (instant keyboard response):
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
setSelectedIndex(i => i + 1)
}
}
// Instant position update
<div style={{ transform: `translateY(${selectedIndex * 40}px)` }} />
// Keeps up with rapid key pressesTools like Raycast that are used hundreds of times daily have no animations—and that's optimal.
Reference: You Don't Need Animations
Marketing Sites Are the Exception
Marketing and landing pages are exceptions to speed rules. Users visit once or infrequently, so longer, more expressive animations are acceptable.
Incorrect (app-style timing on marketing page):
/* Marketing hero animation */
.hero-element {
transition: transform 200ms ease-out;
}
/* Too fast for marketing, misses opportunity for delight */Correct (expressive timing for infrequent visits):
/* Marketing hero animation */
.hero-element {
animation: floatIn 800ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Longer, more expressive animation appropriate for one-time viewing */Guidelines by context:
- App UI: <300ms, subtle, functional
- Marketing: 500-1000ms OK, expressive, attention-grabbing
- Onboarding: Can be playful, guiding
- Documentation: Minimal, functional
Reference: You Don't Need Animations
Every Animation Must Have a Purpose
The goal is not to animate for animation's sake. Every animation should serve a clear purpose—guiding attention, providing feedback, or maintaining context.
Valid purposes for animation:
- Feedback - Confirming user actions (button press, form submit)
- Orientation - Showing where something came from or went
- Attention - Drawing focus to important changes
- Continuity - Maintaining context during transitions
Incorrect (animation without purpose):
// Random bounce on page load
<motion.h1
animate={{ y: [0, -10, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
>
Welcome
</motion.h1>
// Why is this bouncing? No clear purpose.Correct (animation with purpose):
// Feedback animation on successful action
<motion.div
animate={isSuccess ? { scale: [1, 1.1, 1] } : {}}
>
<CheckIcon />
</motion.div>
// Animation provides feedback for user actionReference: You Don't Need Animations
Review Animations in Slow Motion and the Next Day
Most animation flaws are invisible at full speed. Slow playback 2–5×, step frame-by-frame in DevTools, and re-review with fresh eyes the next day. Test gesture-driven animations on real hardware, not just a simulator.
Incorrect (only ever viewed at full speed on the dev machine):
.feedback-button {
transition: transform 160ms ease-out;
}
/* Origin, easing, and out-of-sync properties hide at 160ms */Correct (slow it down to inspect, then revert):
.feedback-button {
transition: transform 1600ms ease-out; /* 10× slower while debugging only */
}
/* Or use DevTools → Animations to slow playback and step frame-by-frame */What to look for in slow motion:
- Crossfades that show two overlapping states instead of one smooth blend
- Easing that starts or stops abruptly
- A
transform-originthat scales from the wrong point opacity,transform, andcolordrifting out of sync
Reference: 7 Practical Animation Tips
Keep UI Animations Under 300ms
UI animations should stay under 300ms to feel responsive. Longer animations make interfaces feel slow and disconnected from user actions.
Incorrect (slow animation):
.dropdown {
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
/* Feels sluggish, user waits for UI to catch up */Correct (snappy animation):
.dropdown {
transition: opacity 200ms ease-out, transform 200ms ease-out;
}
/* Feels responsive and connected to user action */Duration Guidelines:
- 150–250ms for micro UI changes (buttons, toggles)
- 250–400ms for larger context switches (modals, page transitions)
- Longer durations only for marketing/intro animations
Reference: 7 Practical Animation Tips
Use Asymmetric Timing for Press and Release
Press and release actions have different purposes. Pressing should be slow to allow user confirmation; release should be fast for snappy feedback.
Incorrect (symmetric timing):
.hold-button .progress {
transition: clip-path 500ms ease-out;
}
/* Same speed for both directions feels wrong */Correct (asymmetric timing):
.hold-button .progress {
transition: clip-path 200ms ease-out; /* Fast release */
}
.hold-button:active .progress {
transition: clip-path 2s linear; /* Slow press for confirmation */
}This pattern is used in hold-to-delete and hold-to-confirm interactions where the slow press gives users time to reconsider, while the fast release provides immediate feedback.
Reference: Building a Hold to Delete Component
Use 500ms Duration for Drawer Animations
Drawer components are an exception to the 300ms rule. The 500ms duration with iOS-style easing matches native mobile behavior users expect.
Incorrect (too fast for drawer):
.drawer {
transition: transform 200ms ease-out;
}
/* Feels rushed, doesn't match native behavior */Correct (iOS-matched timing):
.drawer {
transition: transform 500ms cubic-bezier(0.32, 0.72, 0, 1);
}
/* Matches iOS Sheet, feels native and polished */The 500ms duration works because:
- Drawers cover large screen areas
- Users expect mobile-native behavior
- The custom easing makes it feel faster than it is
Reference: Building a Drawer Component
Faster Animations Improve Perceived Performance
Faster animations don't just complete quicker—they make your entire interface feel more responsive and performant. A 180ms animation feels noticeably better than 400ms.
Incorrect (unnecessarily slow):
.select-dropdown {
transition: transform 400ms ease-out;
}
/* Feels slow even though animation is smooth */Correct (appropriately fast):
.select-dropdown {
transition: transform 180ms ease-out;
}
/* Feels snappy and responsive */The Speed Principle:
- Animations improve perceived performance when fast
- Animations degrade perceived performance when slow
- When in doubt, make it faster
Reference: 7 Practical Animation Tips
Delay Initial Tooltips, Instant Subsequent Ones
Tooltips should have a delay before appearing to prevent accidental activation. Once a tooltip is open, subsequent tooltips should appear instantly with no animation.
Incorrect (same delay for all):
.tooltip {
transition: opacity 200ms ease-out;
transition-delay: 300ms;
}
/* Every tooltip waits 300ms, feels slow when exploring */Correct (initial delay, instant subsequent):
.tooltip {
transition: opacity 200ms ease-out;
transition-delay: 300ms;
}
.tooltip[data-instant] {
transition-duration: 0ms;
transition-delay: 0ms;
}// Set data-instant when any tooltip is already open
const [instantTooltips, setInstantTooltips] = useState(false)This feels faster without defeating the purpose of the initial delay.
Reference: 7 Practical Animation Tips
Use preserve-3d for 3D Transform Effects
Combine transform-style: preserve-3d with rotateX() and rotateY() to create 3D effects like card flips, orbiting elements, and depth animations.
Incorrect (flat rotation, no 3D depth):
.card {
transition: transform 500ms ease-out;
}
.card:hover {
transform: rotateY(180deg);
}
/* Card rotates but children flatten, back isn't visible */Correct (preserve-3d maintains depth):
.card-container {
perspective: 1000px;
}
.card {
transform-style: preserve-3d;
transition: transform 500ms ease-out;
}
.card:hover {
transform: rotateY(180deg);
}
.card-front, .card-back {
backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
/* True 3D flip with front/back faces */Mental Model: Think of rotateX/rotateY axes like screws—rotateX rotates around a horizontal axis (like a garage door), rotateY around a vertical axis (like a revolving door).
Reference: CSS Transforms
Never Animate from scale(0)
Elements animating from scale(0) feel unnatural—nothing in the real world appears from nothing. Start from scale(0.9) or higher combined with opacity for gentle, elegant motion.
Incorrect (scale from 0):
.modal {
animation: appear 200ms ease-out;
}
@keyframes appear {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Feels like element appears from nowhere */Correct (scale from 0.9+):
.modal {
animation: appear 200ms ease-out;
}
@keyframes appear {
from { transform: scale(0.95); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
/* Gentle expansion feels natural */The higher initial scale makes movement feel more gentle, natural, and elegant.
Reference: 7 Practical Animation Tips
Make Animations Origin-Aware
Dropdowns and popovers should animate from their trigger element, not from an arbitrary center point. Set transform-origin to match where the animation originates.
Incorrect (default center origin):
.dropdown {
transform-origin: center; /* Default */
animation: scaleIn 200ms ease-out;
}
/* Dropdown scales from middle, disconnected from button */Correct (origin matches trigger):
.dropdown {
transform-origin: top center; /* Matches button position */
animation: scaleIn 200ms ease-out;
}With component libraries:
/* Radix UI */
.dropdown {
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
}
/* Base UI */
.popover {
transform-origin: var(--transform-origin);
}
/* The library calculates the correct origin relative to the trigger */shadcn/ui handles this automatically.
When NOT to use this pattern:
- Modals. Keep
transform-origin: centeron modals—they are not anchored to a trigger, they appear centered in the viewport, so scaling from a trigger point looks wrong.
Reference: Good vs Great Animations
Use Percentage Values for translateY
Use percentage values instead of fixed pixels for translateY. Percentages are relative to the element's own dimensions, automatically adapting to varying content sizes.
Incorrect (fixed pixel value):
.toast {
transform: translateY(60px); /* Assumes toast is 60px tall */
}
/* Breaks if toast height varies */Correct (percentage value):
.toast {
transform: translateY(100%); /* Always moves by its own height */
}
/* Works regardless of toast content/height */This pattern is used in Sonner for toasts and Vaul for variable-height drawers.
Reference: CSS Transforms
Scale Buttons to 0.97 on Press
Add a subtle scale-down effect when buttons are pressed. A scale of 0.97 with ~150ms transition provides instant feedback that makes interfaces feel responsive.
Incorrect (no press feedback):
.button {
background: blue;
}
.button:hover {
background: darkblue;
}
/* No tactile feedback on press */Correct (scale on press):
.button {
background: blue;
transition: transform 150ms ease-out;
}
.button:hover {
background: darkblue;
}
.button:active {
transform: scale(0.97);
}
/* Subtle but noticeable press feedback */This small detail makes the interface feel like it's listening to the user.
Reference: 7 Practical Animation Tips
Scale Transforms Affect Children
Unlike width/height changes, scale transforms apply proportionally to all child elements. This can be a feature (cohesive scaling) or a bug (unwanted text distortion).
Incorrect (scale when text should stay readable):
function ZoomableContainer({ children, zoom }) {
return (
<div style={{ transform: `scale(${zoom})` }}>
{children} {/* Text becomes unreadable at low zoom */}
</div>
)
}
// All children including text scale proportionallyCorrect (use opacity + translate when children shouldn't scale):
function FadeContainer({ children, visible }) {
return (
<div style={{
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(10px)'
}}>
{children} {/* Children maintain original size */}
</div>
)
}
// Children stay readable, only position/opacity changeWhen scale IS desired: Card hover effects, zoom interfaces, thumbnail previews where everything should grow together.
Reference: CSS Transforms
Animate Enter States with @starting-style
@starting-style defines the values an element animates from on first render, letting you animate entry with pure CSS. It replaces the common React pattern of flipping a mounted flag in useEffect just to trigger the enter transition—which costs an extra render and can flash the final state for one frame.
Incorrect (JavaScript mount flag):
function Toast({ message }: { message: string }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return <div data-mounted={mounted} className="toast">{message}</div>
}
// Needs JS plus a re-render solely to start the enter animationCorrect (@starting-style, no JS):
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}When NOT to use this pattern:
- You must support browsers without
@starting-style—keep thedata-mountedattribute pattern as a fallback there
Reference: Great Animations
Split Press and Release Timing with active:duration in Tailwind v4
Press and release often want different speeds. A hold-to-confirm control should fill slowly so the action feels deliberate, then reset instantly the moment the pointer lifts. A single duration-* makes the release as sluggish as the press. Apply the slow duration only while pressed with the active: variant; the base duration handles the fast release.
Incorrect (symmetric duration—slow to reset):
// 2s on the way in AND on the way out: release feels broken
<button className="transition-[clip-path] duration-[2000ms] ease-out">
Hold to delete
</button>Correct (slow press, fast release):
// active: applies 2s while held; base 200ms snaps it back on release
<button className="transition-[clip-path] ease-out duration-200 active:duration-[2000ms]">
Hold to delete
</button>Notes:
- The same split works with
data-*state variants when the open/close state is JS-driven, e.g.duration-200 data-[open]:duration-500.
Reference: Building a Hold to Delete Component
Register Custom Easing Curves as @theme Tokens in Tailwind v4
Emil's signature curves (strong ease-out, on-screen ease-in-out, iOS drawer) are worth more than the built-in ease-out. Instead of repeating ease-[cubic-bezier(...)] at every call site—where a single transposed digit drifts the feel—define each curve once under the --ease-* theme namespace. Tailwind v4 generates a matching ease-* utility automatically.
Incorrect (inline arbitrary curve, repeated):
// The same 30-character curve duplicated, easy to mistype
<div className="transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]" />
<div className="transition-transform duration-500 ease-[cubic-bezier(0.32,0.72,0,1)]" />Correct (define once in @theme, reference by name):
/* app.css */
@import "tailwindcss";
@theme {
--ease-snappy: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
--ease-onscreen: cubic-bezier(0.77, 0, 0.175, 1); /* on-screen movement */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-style drawer/sheet */
}// Curves are named, consistent, and editable in one place
<div className="transition-transform duration-200 ease-snappy" />
<div className="transition-transform duration-500 ease-drawer" />Reference: Great Animations
Set ease-out and a Sub-300ms Duration Explicitly in Tailwind v4
transition-all animates every changed property—including layout properties like width and top—and falls back to Tailwind's default timing function (cubic-bezier(0.4, 0, 0.2, 1), an ease-in-out). For UI, scope the transition to transform and opacity, set ease-out, and keep the duration under 300ms so the motion feels like a quick response rather than a slow settle.
Incorrect (transition-all + default curve):
// Animates layout props on any change and uses the default ease-in-out curve
<div className="transition-all duration-300" />Correct (compositor-only properties + explicit ease-out):
// Only transform and opacity animate; ease-out at 200ms feels responsive
<div className="transition-[transform,opacity] duration-200 ease-out" />Notes:
transition-transform,transition-opacity, andtransition-colorsare narrower built-ins when you animate a single property group.- Reserve durations of 300–500ms for large surfaces (modals, drawers); see `tw-asymmetric-timing` for press/release splits.
Reference: Great Animations
Set transform-origin with origin-* Utilities in Tailwind v4
A scale animation grows from its transform-origin. The default center makes a dropdown appear to inflate from its own middle, disconnected from the control that opened it. Match the origin to the anchor with an origin-* utility so the surface looks like it emerges from the trigger.
Incorrect (default center origin):
// Menu anchored under a top-left button still scales from its center
<div className="scale-95 opacity-0 transition-[transform,opacity] duration-150 ease-out data-[open]:scale-100 data-[open]:opacity-100" />Correct (origin matched to the anchor):
// Grows out of the top-left corner, toward the menu body
<div className="origin-top-left scale-95 opacity-0 transition-[transform,opacity] duration-150 ease-out data-[open]:scale-100 data-[open]:opacity-100" />Notes:
- Origin utilities cover all nine positions, e.g.
origin-top,origin-bottom-right,origin-left; pick the one nearest the trigger.
Reference: CSS Transforms
Use active:scale-[0.97] for Button Press Feedback in Tailwind v4
A subtle scale-down on press makes a control feel responsive. In Tailwind v4 hover: only applies on devices with a hover-capable pointer, so a hover-driven effect gives touch users no feedback—use active: for the press itself. The 0.97 value is not on the default scale, so it needs an arbitrary value; transforms compose automatically in v4, so no transform utility is required.
Incorrect (hover-driven, wrong magnitude):
// hover: never fires on touch; scale-90 is too large for a press
<button className="transition-transform hover:scale-90">Save</button>Correct (active: press feedback at 0.97):
// Fires on both pointer and touch; lands in 150ms
<button className="transition-transform duration-150 ease-out active:scale-[0.97]">
Save
</button>Notes:
- Pair with
hover:only for an additional resting-state hint, never as the sole feedback—see `tw-reduced-motion` to gate motion for sensitive users.
Reference: Great Animations
Gate Movement Behind motion-safe / motion-reduce in Tailwind v4
Opacity changes do not affect perceived position or size, so they are safe under prefers-reduced-motion, while transforms can trigger discomfort. Rather than removing animation entirely, keep a gentle fade: let the transform run only when motion is allowed, and fall back to an opacity transition otherwise. Tailwind's motion-safe: and motion-reduce: variants map straight to the media query.
Incorrect (unconditional transform):
// Slides for everyone, including users who asked for reduced motion
<div className="transition-[transform,opacity] duration-300 ease-out translate-y-0 opacity-100" />Correct (transform only when safe, opacity fallback otherwise):
// motion-safe adds the slide; motion-reduce keeps a calm fade
<div className="opacity-100 transition-opacity duration-300 ease-out motion-safe:transition-[transform,opacity] motion-safe:translate-y-0" />Notes:
- Prefer a softened alternative over
motion-reduce:transition-none; a one-property fade still communicates state change without motion.
Reference: Great Animations
Animate Enter States with the starting: Variant in Tailwind v4
Tailwind v4 exposes @starting-style through the starting: variant, so an element can animate from its initial values on first render with no JavaScript. This replaces the React pattern of flipping a mounted flag in useEffect purely to trigger the enter transition—which costs an extra render and can flash the final state for one frame. Add transition-discrete when the element also toggles display (popovers, dialogs).
Incorrect (JS mount flag):
function Toast({ message }: { message: string }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
return (
<div
className={`transition-[transform,opacity] duration-300 ease-out ${
mounted ? "translate-y-0 opacity-100" : "translate-y-full opacity-0"
}`}
>
{message}
</div>
)
}Correct (starting: variant, no mount flag):
function Toast({ message }: { message: string }) {
return (
<div className="transition-discrete transition-[transform,opacity] duration-300 ease-out translate-y-0 opacity-100 starting:translate-y-full starting:opacity-0">
{message}
</div>
)
}When NOT to use this pattern:
- You must support browsers without
@starting-style—keep themountedtoggle as a fallback there.
Reference: Building a Toast Component
Scope will-change-transform to the Active Gesture in Tailwind v4
will-change-transform hints the browser to promote an element to its own compositor layer, which removes a 1px sub-pixel shift during transform animations. Leaving it on permanently keeps those layers alive at rest and consumes memory. Apply it only while the element is actually animating—bind it to the interaction state with a data-* variant—then drop it when the gesture ends.
Incorrect (permanent promotion):
// Layer stays allocated even when the sheet is idle
<div className="will-change-transform transition-transform duration-300 ease-out" />Correct (scoped to the drag state):
// will-change only while data-dragging is set; gone at rest
<div
data-dragging={isDragging}
className="transition-transform duration-300 ease-out data-[dragging=true]:will-change-transform"
/>Notes:
- Reach for this only after a measurable sub-pixel shift appears; promoting every element costs more than it saves.
Reference: Great Animations
Related skills
How it compares
Pick emilkowal-animations over generic CSS transition tips when you need 43 prioritized motion rules with production code pairs for React and Framer Motion.
FAQ
What is emilkowal-animations?
Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in React, CSS, or Framer Motion. Triggers on tasks involving tr
When should I use emilkowal-animations?
Emil Kowalski's animation best practices for web interfaces. Use when writing, reviewing, or implementing animations in React, CSS, or Framer Motion. Triggers on tasks involving tr
Is emilkowal-animations safe to install?
Review the Security Audits panel on this page before production use.