Motion and polish
Whether a thing should animate at all, how fast, with what easing — and the unseen details that compound into software that feels deliberate.
Adapted from the emil-design-eng skill — upstream
emilkowalski/skill. Deeper treatment at
animations.dev.
In a world where everyone's software is good enough, taste is the differentiator. Good taste is not personal preference: it is a trained instinct, developed by studying why the best interfaces feel the way they do.
Unseen details compound. Most details users never consciously notice — that is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought.
"All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." — Paul Graham
This is one of a pair: motion answers how things behave, layout and spacing answers where they sit. A decision that needs one usually needs both.
The animation decision framework
Answer these in order, before writing any animation code.
1 — Should this animate at all?
How often will users see it?
| Frequency | Decision |
|---|---|
| 100+ times a day (keyboard shortcuts, command palette) | No animation. Ever. |
| Tens of times a day (hover effects, list navigation) | Remove or drastically reduce |
| Occasional (modals, drawers, toasts) | Standard animation |
| Rare or first-time (onboarding, feedback, celebrations) | Can add delight |
Never animate keyboard-initiated actions. They are repeated hundreds of times daily; animation makes them feel slow and disconnected. Raycast has no open/close animation, and that is the optimal experience for something used that often.
2 — What is the purpose?
Every animation needs a clear answer to why does this animate?
- Spatial consistency — a toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive.
- State indication — a morphing button shows the state change.
- Explanation — a marketing animation showing how a feature works.
- Feedback — a button scales down on press, confirming the interface heard the user.
- Preventing jarring changes — elements appearing or disappearing without transition feel broken.
If the purpose is "it looks cool" and the user will see it often, do not animate.
3 — What easing?
| Situation | Easing |
|---|---|
| Entering or exiting | ease-out — starts fast, feels responsive |
| Moving or morphing on screen | ease-in-out |
| Hover or colour change | ease |
| Constant motion (marquee, progress bar) | linear |
| Default | ease-out |
Use custom curves. The built-in CSS easings are too weak; they lack the punch that makes animation feel intentional.
/* 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 curve (from Ionic) */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);Never use ease-in for UI animations. It starts slow, which makes the
interface feel sluggish. A dropdown with ease-in at 300ms feels slower than
ease-out at the same 300ms, because it delays the initial movement — the exact
moment the user is watching most closely.
4 — How fast?
| 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 |
UI animations stay under 300ms. A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical. Perception of speed matters as much as actual speed.
Springs
Springs simulate physics: no fixed duration, they settle. Use them for drag interactions with momentum, elements that should feel alive, gestures that can be interrupted mid-animation, and decorative mouse-tracking.
// Apple's parameterisation — easier to reason about
{ type: "spring", duration: 0.5, bounce: 0.2 }
// Traditional physics — more control
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }Keep bounce subtle (0.1–0.3), and avoid it in most UI contexts. Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero — which is what makes them right for gestures users might change mid-motion.
Tying visual change directly to mouse position feels artificial because it lacks motion; interpolate with a spring instead. That works because the animation is decorative. In a functional graph in a banking app, no animation would be better.
Component building principles
Buttons must feel responsive
.button {
transition: transform 160ms ease-out;
}
.button:active {
transform: scale(0.97);
}Applies to any pressable element. Keep the scale subtle — 0.95 to 0.98.
Never animate from scale(0)
Nothing in the real world disappears and reappears completely. Start from
scale(0.9) or higher, combined with opacity — like a balloon that has a visible
shape even when deflated.
Make popovers origin-aware
Popovers should scale in from their trigger, not from centre.
/* Radix UI */
.popover { transform-origin: var(--radix-popover-content-transform-origin); }
/* Base UI */
.popover { transform-origin: var(--transform-origin); }Exception: modals. They are not anchored to a trigger, so they keep
transform-origin: center.
Tooltips: skip the delay on subsequent hovers
Delay before the first tooltip appears, to prevent accidental activation. Once one is open, hovering an adjacent tooltip opens it instantly with no animation. This makes the whole toolbar feel faster without defeating the initial delay.
Transitions over keyframes for interruptible UI
CSS transitions can be interrupted and retargeted mid-animation; keyframes restart from zero. For anything triggered rapidly — adding toasts, toggling states — transitions produce smoother results.
Blur masks an imperfect transition
When a crossfade between two states feels off despite trying different easings
and durations, add a subtle filter: blur(2px) during the transition. Without
blur you see two distinct objects overlapping; blur blends them into a single
perceived transformation. Keep it under 20px — heavy blur is expensive,
especially in Safari.
Animate entry with @starting-style
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}This replaces the React pattern of setting mounted: true in a useEffect after
first render. Fall back to the data-mounted attribute pattern where browser
support requires it.
Transform mastery
translateYwith percentages is relative to the element's own size:translateY(100%)moves it by its own height regardless of dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in. Prefer percentages over hardcoded pixels.scale()scales children too — font size, icons and content scale proportionally when a button is pressed. That is a feature.- 3D transforms —
rotateX(),rotateY()withtransform-style: preserve-3dcreate real depth without JavaScript. transform-originis the anchor point every transform executes from. Set it to match where the trigger lives.
clip-path for animation
clip-path: inset(top right bottom left) defines a rectangular clipping region;
each value eats into the element from that side.
.hidden { clip-path: inset(0 100% 0 0); } /* fully hidden from the right */
.visible { clip-path: inset(0 0 0 0); }Four patterns it unlocks:
| Pattern | How |
|---|---|
| Tabs with perfect colour transitions | Duplicate the tab list, style the copy as active, clip so only the active tab shows, animate the clip |
| Hold-to-delete | inset(0 100% 0 0) on a coloured overlay → inset(0 0 0 0) over 2s linear on :active; snap back in 200ms ease-out on release |
| Image reveals on scroll | inset(0 0 100% 0) → inset(0 0 0 0) on IntersectionObserver entry |
| Comparison sliders | Overlay two images, clip the top one, adjust the inset from drag position — no extra DOM |
Gesture and drag
- Momentum-based dismissal. Do not require dragging past a threshold.
Compute
Math.abs(dragDistance) / elapsedTime; if velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough. - Damping at boundaries. Dragging past the natural boundary moves the element less the further it goes. Things in real life do not suddenly stop.
- Pointer capture. Once dragging starts, capture pointer events so the drag continues when the pointer leaves the element.
- Multi-touch protection. Ignore additional touch points after the drag begins, or switching fingers mid-drag makes the element jump.
- Friction instead of hard stops. Allow the movement with increasing friction rather than hitting an invisible wall.
Performance
- Only animate
transformandopacity. They skip layout and paint and run on the GPU.padding,margin,heightandwidthtrigger all three. - CSS variables are inheritable. Changing one on a parent recalculates styles
for every child. Update
transformdirectly on the element instead. - Framer Motion shorthands are not hardware-accelerated.
x,y,scaleuserequestAnimationFrameon the main thread. Use the full transform string —animate={{ transform: "translateX(100px)" }}— where smoothness under load matters. - CSS animations beat JS under load. They run off the main thread. Use CSS for predetermined animations, JS for dynamic interruptible ones.
- WAAPI —
element.animate([...], { duration, easing })— gives JavaScript control with CSS performance, hardware-accelerated and interruptible, with no library.
Accessibility
prefers-reduced-motion means fewer and gentler animations, not zero. Keep
opacity and colour transitions that aid comprehension; remove movement and
position animations.
@media (prefers-reduced-motion: reduce) {
.element { animation: fade 0.2s ease; }
}Gate hover animations behind @media (hover: hover) and (pointer: fine) —
touch devices trigger hover on tap, causing false positives.
Building components people love
From Sonner (13M+ weekly downloads), applicable to any component:
- Developer experience is key. No hooks, no context, no complex setup. The less friction to adopt, the more people use it.
- Good defaults matter more than options. Ship beautiful out of the box — most users never customise.
- Naming creates identity. Sacrifice discoverability for memorability when appropriate.
- Handle edge cases invisibly. Pause timers when the tab is hidden, fill gaps between stacked toasts to maintain hover state, capture pointer events during drag. Users never notice, and that is exactly right.
- Use transitions, not keyframes, for dynamic UI.
- Build a great documentation site. Let people touch the product before they use it.
Cohesion matters. Match the motion to the mood: a playful component can be
bouncier, a professional dashboard should be crisp and fast. Sonner is slightly
slower than typical UI animation and uses ease rather than ease-out to feel
more elegant — and that fits everything else about it.
Asymmetric enter/exit timing. Slow where the user is deciding (hold-to-delete: 2s linear), fast where the system is responding (release: 200ms ease-out).
Stagger entering lists by 30–80ms per item. Longer delays make the interface feel slow. Stagger is decorative — never block interaction while it plays.
Review your work the next day. Fresh eyes catch imperfections you missed during development.
Debugging
- Slow motion. Increase duration 2–5× or use the DevTools animation
inspector. Look for: two distinct states overlapping in a crossfade, easing
that starts or stops abruptly, a wrong
transform-origin, animated properties out of sync. - Frame by frame in the Chrome Animations panel reveals timing issues between coordinated properties.
- Test on real devices for touch interactions. Connect the phone, visit the dev server by IP, use Safari remote devtools. The simulator is a fallback, not a substitute.
Review checklist
When reviewing UI code, report findings as a markdown table with Before / After / Why columns — one row per issue. Never a list with "Before:" and "After:" on separate lines.
| Issue | Fix |
|---|---|
transition: all | Specify exact properties: transition: transform 200ms ease-out |
scale(0) entry animation | Start from scale(0.95) with opacity: 0 |
ease-in on a UI element | Switch to ease-out or a custom curve |
transform-origin: center on a popover | Set to the trigger location or the library's CSS variable (modals exempt) |
| Animation on a keyboard action | Remove the animation entirely |
| Duration over 300ms on a UI element | Reduce to 150–250ms |
| Hover animation without a media query | Add @media (hover: hover) and (pointer: fine) |
| Keyframes on a rapidly-triggered element | Use CSS transitions for interruptibility |
Framer Motion x/y props under load | Use transform: "translateX()" |
| Same enter and exit speed | Make the exit faster than the enter |
| Elements all appearing at once | Add a 30–80ms stagger |