Architect
How-to guides
ReferenceNormative

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?

FrequencyDecision
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?

SituationEasing
Entering or exitingease-out — starts fast, feels responsive
Moving or morphing on screenease-in-out
Hover or colour changeease
Constant motion (marquee, progress bar)linear
Defaultease-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?

ElementDuration
Button press feedback100–160ms
Tooltips, small popovers125–200ms
Dropdowns, selects150–250ms
Modals, drawers200–500ms
Marketing / explanatoryCan 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

  • translateY with 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 transformsrotateX(), rotateY() with transform-style: preserve-3d create real depth without JavaScript.
  • transform-origin is 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:

PatternHow
Tabs with perfect colour transitionsDuplicate the tab list, style the copy as active, clip so only the active tab shows, animate the clip
Hold-to-deleteinset(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 scrollinset(0 0 100% 0)inset(0 0 0 0) on IntersectionObserver entry
Comparison slidersOverlay 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 transform and opacity. They skip layout and paint and run on the GPU. padding, margin, height and width trigger all three.
  • CSS variables are inheritable. Changing one on a parent recalculates styles for every child. Update transform directly on the element instead.
  • Framer Motion shorthands are not hardware-accelerated. x, y, scale use requestAnimationFrame on 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.
  • WAAPIelement.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:

  1. Developer experience is key. No hooks, no context, no complex setup. The less friction to adopt, the more people use it.
  2. Good defaults matter more than options. Ship beautiful out of the box — most users never customise.
  3. Naming creates identity. Sacrifice discoverability for memorability when appropriate.
  4. 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.
  5. Use transitions, not keyframes, for dynamic UI.
  6. 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.

IssueFix
transition: allSpecify exact properties: transition: transform 200ms ease-out
scale(0) entry animationStart from scale(0.95) with opacity: 0
ease-in on a UI elementSwitch to ease-out or a custom curve
transform-origin: center on a popoverSet to the trigger location or the library's CSS variable (modals exempt)
Animation on a keyboard actionRemove the animation entirely
Duration over 300ms on a UI elementReduce to 150–250ms
Hover animation without a media queryAdd @media (hover: hover) and (pointer: fine)
Keyframes on a rapidly-triggered elementUse CSS transitions for interruptibility
Framer Motion x/y props under loadUse transform: "translateX()"
Same enter and exit speedMake the exit faster than the enter
Elements all appearing at onceAdd a 30–80ms stagger

On this page