Skip to content

Animation & Motion

A UI principle for coding agents. Also covers motion, transitions, easing, cubic-bezier, timing function, custom easing curve, and 7 more.

Show all 13 aliases

motion, transitions, easing, cubic-bezier, timing function, custom easing curve, easing tokens, my animation teleports, springs, fluid motion, gesture animation, direct manipulation, micro interactions

Core Philosophy

  • Animations should make interfaces feel responsive and alive
  • Users should receive immediate feedback for all actions
  • Prioritize perceived performance over visual complexity
  • Unseen details compound into exceptional experiences

Micro-Interaction Priorities

Not every interaction deserves animation. Before adding motion, ask: "If I remove this, does the user lose information?" If yes — a state change becomes invisible, a spatial relationship breaks, feedback disappears — keep it. If no, it's decoration.

Keep these — they answer "did that work?":

  • Button press feedback (active:scale-95) — highest-value, one-line CSS, makes every tap feel acknowledged
  • Toggle state transitions — smooth transition between positions tells the user what changed
  • Content entering/leaving (modals, panels, drawers) — shows where things came from and went, maintains spatial consistency
  • Loading indicators — the user clicked something and nothing changed visually? They'll click again and again
  • Hover states on interactive elements — background shift, subtle lift, underline appearing — signals that something is clickable

Cut these — they don't answer anything:

  • Scroll-triggered entrance animations — content is already there, show it. Making users wait for content they've already scrolled to feels slow.
  • Fake loading (staggered fade-in on static content) — manufacturing a delay that doesn't exist
  • Hover on non-interactive elements — every hover effect is a promise that something will happen on click
  • Complex page transitions — full-page slide transitions add latency to every navigation
  • Parallax — disorienting for motion-sensitive users, expensive to render, almost never serves the content

Easing rule of thumb:

  • ease-out (fast start, slow end) for entrances/arrivals — feels like something settling. 150–250ms.
  • ease-in (slow start, fast end) for exits — feels like something falling away.
  • linear almost never looks right for UI, because things in the real world don't move at constant speed. The exception is continuous rotation, covered in "What Each Built-In Curve Is Actually For" below.
  • ease-in-out often feels mushy for one-way motion, since the slow start adds perceived latency. It is the right choice for alternating oscillation, for the reason given in that same section.

Always respect user preferences. Motion you write yourself belongs inside @media (prefers-reduced-motion: no-preference), so it only ever applies when motion is welcome:

@media (prefers-reduced-motion: no-preference) {
  .panel {
    transition: opacity 200ms ease, transform 200ms ease;
  }
}

prefers-reduced-motion has exactly two values, no-preference and reduce, so a browser that cannot evaluate the feature matches neither. Gating motion in means that browser gets a still page; switching motion off in a reduce block means it gets every animation at full strength. accessibility.md has the full reasoning.

Underneath that, keep one blanket sweep as a safety net for the CSS you did not write, or have not gated yet:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

This one stays in the reduce direction on purpose. It resets a property on every element rather than declaring motion, and there is no way to blanket-restore each rule's own duration inside a no-preference block, so it cannot be inverted. It is a net, not a substitute for gating your own motion in. It is also the override the rest of this doc refers to when it says CSS durations collapse to ~0ms.

Neither form removes state changes. The button still looks pressed, the toggle still moves. Transitions just happen instantly. Information is preserved, motion is removed.

Decision Framework: Springs vs Easing Curves

Before choosing animation techniques, ask a single question: Is this motion reacting to the user, or is the system speaking?

  • Springs → Motion stays attached to user input, survives interruption, preserves velocity
  • Easing Curves → System announces changes, guides attention, helps things land clearly

You are not choosing between animation techniques. You are choosing the role motion plays in the interaction.

When to Use None

Not everything needs animation. Animation actively makes things worse for:

  • High-frequency interactions (typing, keyboard navigation)
  • Fast toggles and rapid state changes
  • Actions performed 10+ times daily

Choosing no motion is a design decision that prioritizes immediacy and predictability over expressiveness.

When to Use Springs

Springs work best when motion is directly tied to user input. Use springs for:

  • Dragging and flicking interactions
  • Gesture-driven interfaces
  • Any input that can be interrupted mid-motion
  • Interactive elements that need to preserve velocity

Key characteristic: Springs have no predefined end time - they resolve naturally based on physics. This makes them resilient to interruption.

// Spring example - no duration, describes behavior
transition={{
  type: "spring",
  stiffness: 900,   // How strongly it pulls to target
  damping: 80,      // How quickly energy is removed
  mass: 10,         // How heavy the object feels
}}

The tradeoff: Springs can feel restless when the system is simply announcing a state change.

  • Default to spring animations when using Framer Motion.
  • Avoid using bouncy spring animations unless you are working with drag gestures.
  • Start serious UI springs at critical damping: fast settle, no overshoot. Add bounce only when the user supplied momentum through a drag, flick, or throw.

Fluid Motion Rules

For gesture-driven UI, the animation model must behave like the user can grab the pixels at any time.

  • Animate from the presentation value, not the old target. If a drawer is halfway closed and the user reverses direction, continue from the visible position on screen. Jumping back to the logical start or end point breaks the illusion.
  • Retarget instead of restarting. A spring that changes destination mid-flight should keep its current velocity. Keyframes and fixed tweens often restart from frame zero, which makes interrupted motion feel mechanical.
  • Hand off velocity on release. Track the last few pointer positions and timestamps during a drag, then pass release velocity into the settling animation. A flicked card should keep moving in the flick direction before it settles.
  • Project momentum before choosing a destination. For sheets, drawers, carousels, and swipe actions, decide the resting point from both distance and velocity. A short fast flick can advance farther than a long slow drag.
  • Use soft boundaries. Past natural limits, apply resistance instead of clamping movement to a hard stop. The object can move a little beyond the edge, then spring back when released.
  • Separate axes when needed. If X and Y have different velocities, animate them independently. One combined 2D spring can curve or lag in a way that does not match the gesture.

When to Use Linear

Linear motion works when the animation represents time itself, or when it never stops:

  • Progress bars and loaders
  • Scrubbing interactions
  • Any animation where users need to judge remaining time
  • Continuous rotation, such as a loading spinner

Why it works: Preserves one-to-one relationship between time and progress. Easing would break accurate time perception.

Why rotation is the same case: a spinner has no start and no end, only an arbitrary point where the keyframes wrap. Any easing puts an accelerate-and-decelerate beat on every revolution, so the loop visibly pulses at the seam. A real wheel does not do that. This is the one place linear does not read robotic.

.progress-bar {
  transition: width 2000ms linear;
}

When to Use Easing Curves

Easing curves have a predefined start and end time. Use them for system-driven responses:

Ease-out (starts fast, slows down):

  • Element entrances
  • User feedback responses
  • Opening animations

Ease-in (starts slow, speeds up):

  • Element exits
  • Dismissals
  • Closing animations
  • Should generally be avoided as it makes the UI feel slow

Ease-in-out (smooth throughout):

  • Transitions between equally important states
  • View switching
  • Mode toggles

Key difference from springs: Easing curves have fixed durations and fall apart when interrupted. Springs adapt to interruption naturally.

What Each Built-In Curve Is Actually For

Every built-in keyword is sugar for a specific cubic-bezier(). Each has one situation where it is the right answer, and outside that situation most of them are too mild to read as anything.

  • linear for continuous rotation, and for motion that stands in for elapsed time. See "When to Use Linear" above.
  • ease-in for things leaving view. The acceleration implies the element keeps going past the edge rather than stopping just out of sight.
  • ease-out for things arriving. Slowing to a stop reads as settling instead of hitting a wall.
  • ease-in-out for oscillation that alternates. It is cubic-bezier(0.42, 0, 0.58, 1), symmetric about its midpoint, so it accelerates out of each end exactly as hard as it decelerates into the other. That symmetry is what a there-and-back needs. The same symmetry is what makes it sluggish for one-way motion, where the slow start is pure perceived latency.
  • ease is the default when no timing function is given, and it is the one built-in worth using directly. It is cubic-bezier(0.25, 0.1, 0.25, 1). Both control points sit at x 0.25, so the curve does most of its work early and then spends the remainder of the duration decelerating. That asymmetry is why it reads snappy where the symmetric ease-in-out reads mushy, and why it is broadly useful instead of situational.

Easing barely matters for a pure fade. Opacity has no position for the eye to track, so there is nothing to judge the rate of change against. Time spent tuning a curve for a fade is wasted; spend it on the properties that move.

Easing Reference

Don't use built-in CSS easings unless it's ease or linear (see the section above for why ease earns the exemption). Use these custom cubic-bezier curves:

ease-in (avoid unless exiting):

  • ease-in-quad: cubic-bezier(.55, .085, .68, .53)
  • ease-in-cubic: cubic-bezier(.550, .055, .675, .19)
  • ease-in-quart: cubic-bezier(.895, .03, .685, .22)
  • ease-in-quint: cubic-bezier(.755, .05, .855, .06)
  • ease-in-expo: cubic-bezier(.95, .05, .795, .035)
  • ease-in-circ: cubic-bezier(.6, .04, .98, .335)

ease-out (best for entering/user-initiated):

  • ease-out-quad: cubic-bezier(.25, .46, .45, .94)
  • ease-out-cubic: cubic-bezier(.215, .61, .355, 1)
  • ease-out-quart: cubic-bezier(.165, .84, .44, 1)
  • ease-out-quint: cubic-bezier(.23, 1, .32, 1)
  • ease-out-expo: cubic-bezier(.19, 1, .22, 1)
  • ease-out-circ: cubic-bezier(.075, .82, .165, 1)

ease-in-out (for elements moving within the screen):

  • ease-in-out-quad: cubic-bezier(.455, .03, .515, .955)
  • ease-in-out-cubic: cubic-bezier(.645, .045, .355, 1)
  • ease-in-out-quart: cubic-bezier(.77, 0, .175, 1)
  • ease-in-out-quint: cubic-bezier(.86, 0, .07, 1)
  • ease-in-out-expo: cubic-bezier(1, 0, 0, 1)
  • ease-in-out-circ: cubic-bezier(.785, .135, .15, .86)

Reference: easings.co for quality easing curves.

Author A Curve Instead Of Picking One

The table above is a lookup, not a method. Randomly dragging Bézier handles until something looks acceptable is how most bad curves get made. Use this instead:

  1. Pick the built-in preset that matches the type of motion. Entrance takes ease-out, exit takes ease-in, alternating oscillation takes ease-in-out. This decides the shape before you touch a number.
  2. Exaggerate it. The built-in presets are deliberately mild, mild enough that ease-in and ease-out both read as nearly linear at ordinary durations. Push the handles further in the same direction and you get what the preset was trying to be. Every curve in the reference table above is an exaggerated version of one of the four presets.
  3. If exaggerating produces a teleport, fix it with duration, not by backing off the curve.

The teleport is the failure mode to watch for, and the phrase for it is "my element jumps from one side to the other with nothing in between". An extreme curve compresses nearly all of the travel into a sliver of the duration, so at a short duration the browser paints almost no frames through the middle of the transition. The eye sees the start position and the end position and no journey.

Perceived Speed Comes From The Curve, Not The Duration

The fix for a teleport is more time, because the problem was never the curve. A deliberately extreme ease-out run over a long duration still feels fast: most of the travel happens in the first couple of hundred milliseconds, and the rest of the duration is a long, smooth settle nobody perceives as waiting. The element arrives when it looks like it arrives.

This is in genuine tension with the duration table below, and the tension is the useful part. Reconcile it like this:

  • Keep the short default for ordinary interface feedback. Hovers, presses, toggles, tooltips, dropdowns, anything the user triggers repeatedly. At those frequencies a long tail is retriggered before it finishes, so it buys nothing and costs responsiveness.
  • Reach for long-and-front-loaded for singular arrivals, where the motion itself is meant to be noticed: a modal or sheet entering, a large surface settling into place, a marketing-page reveal. Here the settle is the payoff, and a duration well past the table can still feel zippy.
  • Judge it by watching it, not by reading the number. A duration that looks alarming in the CSS can be the right one; a duration inside the guidelines can feel slow if the curve is mild and the motion drags through the middle.
  • Under prefers-reduced-motion a long front-loaded entrance collapses like any other. Cut the transition, keep the end state.

Control Points Outside The 0 To 1 Range

The x coordinates of a cubic-bezier() must sit between 0 and 1. The output values do not have to. A negative output pulls the element backwards before it sets off, which reads as anticipation; an output above 1 carries it past the target and back, which reads as overshoot.

.ball {
  transition: translate 300ms cubic-bezier(0.3, 0.8, 0.3, 2.3);
}

These curves are a poor spring approximation. Do not reach for one when the brief says springy or bouncy. Two control points buy exactly one change of direction, so the best a Bézier can do is a single soft overshoot. It cannot cross the target, come back, cross again and settle, which is what a bounce is. The result gestures at springiness and lands stiff. Use linear() instead: spring-easing-in-css.md covers it.

Tokens Carry Most Of The Motion

A product does not need a bespoke curve per animation.

  • Global easing tokens should carry the large majority of the motion: hovers, entrances, small state changes, anything that repeats or appears in more than one place. Consistency is worth more here than a per-component optimum
  • Author a one-off curve only for the small number of deliberately splashy moments where the curve is the point
  • If most components are reaching past the tokens for a one-off, the tokens are wrong. Fix the token set rather than multiplying exceptions

Duration Guidelines

Keep UI animations under 300ms as a rule for ordinary interface feedback. Faster animations improve perceived performance. The one deliberate exception is the long front-loaded curve described above, where the travel is over early and the remainder is a settle.

  • Presses/hovers: 120-180ms
  • Tooltips: 125ms
  • Quick transitions: 150-180ms
  • Small state changes: 180-260ms
  • Dropdowns: 200-300ms
  • Large transitions: up to 300ms
  • Animations should never be longer than 1s, unless they are illustrative or run on a strongly front-loaded curve where the tail is a settle rather than travel
  • Remove animations entirely for frequently-used interactions (10+ times daily)

Hover Transitions

  • Use the built-in CSS ease with a duration of 200ms for simple hover transitions like color, background-color, opacity.
  • Fall back to easing rules for more complex hover transitions.
  • Disable hover transitions on touch devices with the @media (hover: hover) and (pointer: fine) media query.

Preventing Hover Flicker

When hover effects cause the element to move or scale, the cursor can leave the hover area, causing a flickering loop.

The fix: Separate the trigger from the effect. Listen for hovers on a parent element, but animate a child element instead.

Key principles:

  • The hover trigger should be a stable container that doesn't move
  • The animated element should be a child that can transform freely
  • Never animate the same element that defines the hover area

Implementation with Motion:

// Bad: Hover and animation on the same element causes flicker
<motion.div
  whileHover={{ scale: 1.1, y: -4 }}
  className="card"
>
  Card content
</motion.div>

// Good: Parent handles hover, child handles animation
<motion.div whileHover="hover" className="card-wrapper">
  <motion.div
    variants={{ hover: { scale: 1.1, y: -4 } }}
    className="card"
  >
    Card content
  </motion.div>
</motion.div>

CSS-only Alternative:

/* Bad: Same element triggers and animates */
.card:hover {
  transform: scale(1.1) translateY(-4px);
}

/* Good: Parent triggers, child animates */
.card-wrapper:hover .card {
  transform: scale(1.1) translateY(-4px);
}

.card {
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}

Tailwind CSS Alternative:

{/* Bad: Hover and animation on the same element causes flicker */}
<div className="hover:-translate-y-1 hover:scale-105 transition-transform">
  Card content
</div>

{/* Good: Parent handles hover, child handles animation */}
<div className="group">
  <div className="group-hover:-translate-y-1 group-hover:scale-105 transition-transform">
    Card content
  </div>
</div>

Button Interactions

  • Apply subtle scale-down effect on button press using scale(0.97) on :active pseudo-class
  • This creates immediate tactile feedback
.button {
  transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

.button:active {
  transform: scale(0.97); /* Tactile feedback */
}

Scale Animations

  • NEVER animate from scale(0) - it feels unnatural
  • Use initial scale values of 0.9 or higher (0.93 is recommended)
  • Higher initial values mimic real-world physics and feel more elegant

Animating Icons

When icons change contextually (e.g., copy → checkmark, play → pause, like → liked), animate the transition with combined opacity, scale, and blur effects. This makes state changes feel responsive and polished rather than abrupt.

Icon Libraries

  • lucide-animated - 350+ beautifully crafted animated icons. Copy-paste component library (like shadcn/ui) built on Motion. Includes pre-built animations for common patterns: loading spinners, success checkmarks, hover effects, and state transitions. Requires motion as a dependency.

Key principles:

  • Animate icons when they represent state changes or user feedback
  • Use small scale values (0.25) combined with blur for smooth perception
  • Spring animations with zero bounce feel snappy and intentional
  • The blur effect bridges visual gaps during the transition
  • Keep animation short (~300ms) to maintain responsiveness

Implementation with Motion:

import { motion, AnimatePresence } from "motion/react";

<button onClick={handleCopy} className="button">
  <AnimatePresence mode="popLayout" initial={false}>
    <motion.div
      key={isCopied ? "check" : "copy"}
      initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
      exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
      transition={{
        type: "spring",
        duration: 0.3,
        bounce: 0,
      }}
    >
      {isCopied ? <CheckIcon /> : <CopyIcon />}
    </motion.div>
  </AnimatePresence>
</button>

CSS-only Alternative:

@keyframes icon-enter {
  from {
    opacity: 0;
    scale: 0.25;
    filter: blur(4px);
  }
  to {
    opacity: 1;
    scale: 1;
    filter: blur(0px);
  }
}

@keyframes icon-exit {
  from {
    opacity: 1;
    scale: 1;
    filter: blur(0px);
  }
  to {
    opacity: 0;
    scale: 0.25;
    filter: blur(4px);
  }
}

.icon-enter {
  animation: icon-enter 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

.icon-exit {
  animation: icon-exit 300ms cubic-bezier(0.4, 0, 0.2, 1);
}

Split-Text Enter Animations

For a high-impact heading entrance (hero text, a key callout), split the text into words or characters and stagger their enter so the line assembles itself instead of appearing all at once. Reserve this for one or two moments per page, on body copy it is noise, and on content the user scrolled to it just feels slow (see Don't Animate above).

Split granularity:

  • By word is the safe default: it reads as a phrase arriving and keeps screen readers and copy-paste intact when done right.
  • By character is more dramatic but heavier and easy to overdo, use it only on a short hero word.

Stagger + per-piece motion: give each piece a small fade + upward translate (and optionally a touch of blur), then offset each one's start by a fixed delay so they cascade.

import { motion } from "motion/react";

const words = "Design that feels right".split(" ");

// Test the positive query, so a browser that cannot evaluate it renders the text in place.
const motionOk = window.matchMedia("(prefers-reduced-motion: no-preference)").matches;

<h1 aria-label="Design that feels right">
  {words.map((word, i) => (
    <motion.span
      key={i}
      aria-hidden="true"
      className="inline-block"
      initial={{ opacity: 0, y: "0.4em", filter: "blur(4px)" }}
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      transition={{
        delay: motionOk ? i * 0.06 : 0,
        duration: motionOk ? 0.4 : 0,
        ease: [0.4, 0, 0.2, 1],
      }}
    >
      {word}&nbsp;
    </motion.span>
  ))}
</h1>;

Accessibility (non-negotiable):

  • Put the full string in aria-label on the wrapper and mark the split spans aria-hidden, otherwise a screen reader announces the text one fragment at a time.
  • Honor prefers-reduced-motion: skip the stagger and render the text in place. The CSS version keeps the animation inside the no-preference gate; the JS-driven one drops the per-piece delay as well as the duration, since the blanket override at the top of this doc reaches CSS durations only.
  • Keep the total run short: delay * count should stay under ~600ms or the heading feels like it is loading.

CSS-only word stagger (no library) with animation-delay via an index custom property. Set --i as an inline style on each word when you render the markup:

<h1 aria-label="Design that feels right">
  <span class="word" style="--i: 0" aria-hidden="true">Design</span>
  <span class="word" style="--i: 1" aria-hidden="true">that</span>
  <span class="word" style="--i: 2" aria-hidden="true">feels</span>
  <span class="word" style="--i: 3" aria-hidden="true">right</span>
</h1>
@keyframes word-in {
  from { opacity: 0; transform: translateY(0.4em); filter: blur(4px); }
  to   { opacity: 1; transform: translateY(0);     filter: blur(0); }
}

.word {
  display: inline-block;
}

@media (prefers-reduced-motion: no-preference) {
  .word {
    animation: word-in 400ms cubic-bezier(0.4, 0, 0.2, 1) both;
    animation-delay: calc(var(--i) * 60ms);
  }
}

The ungated .word rule carries no opacity or transform, so a browser that never runs the animation paints the heading in place, fully opaque. That is the state to check first: the hidden first frame lives in the keyframes, and the keyframes live in the gate.

Transform Origin

  • Make popovers/dropdowns origin-aware
  • Set transform-origin to match the trigger element position
  • Use CSS variables for dynamic origins:
    • Radix: var(--radix-dropdown-menu-content-transform-origin)
    • Base UI: var(--transform-origin)
  • Never leave transform-origin at default center
.popup {
  transform-origin: var(--transform-origin); /* Origin-aware */
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1), opacity 200ms
      cubic-bezier(0.4, 0, 0.2, 1);
}

.popup[data-state="open"] {
  transform: scale(1);
  opacity: 1;
}

.popup[data-state="closed"] {
  transform: scale(0.95); /* Not 0 - feels more natural */
  opacity: 0;
}

Interruptible Animations

Make your animations interruptible. Users should be able to immediately trigger close events without waiting for the animation to complete. This makes interfaces feel durable, snappy, and well-considered.

Key principles:

  • Opening/closing states should respond instantly to user input
  • In-flight animations should gracefully transition to new states
  • Staggered animations should cancel pending delays on close
  • Never block user interaction waiting for an animation to finish

Implementation with Motion:

Motion makes interruptible animations easy. Use the delay() function which returns a cancel callback:

import { motion, AnimatePresence } from "motion/react";
import { delay } from "motion";

// Store cancel functions in refs
const cancelDelayRef = useRef<(() => void) | null>(null);

// When opening with staggered entrance
useEffect(() => {
  if (isOpen) {
    setShowFirst(true);
    cancelDelayRef.current = delay(() => setShowSecond(true), 0.3);
  }
  return () => cancelDelayRef.current?.();
}, [isOpen]);

// When closing - cancel pending animations immediately
const handleClose = () => {
  cancelDelayRef.current?.(); // Cancel any pending delays
  setShowSecond(false);
  setShowFirst(false);
  setIsOpen(false);
};

Tooltip Behavior

  • First tooltip: Include ~500ms delay to prevent accidental activation
  • Subsequent tooltips: Remove delay AND animation when user is actively exploring
  • Implement using data-instant attribute with transition-duration: 0ms
[data-tooltip] {
  /* First tooltip */
  transition: opacity 200ms;
  transition-delay: 500ms;
}

[data-instant] [data-tooltip] {
  /* Subsequent tooltips */
  transition-delay: 0ms;
}

Blur for Smooth Transitions

  • When animations feel "off" despite correct easing/duration, add filter: blur(2px) during transition
  • Blur bridges visual gaps between states
  • Especially effective for crossfade/state-change animations
  • Combine with scale effects for maximum polish

Cross-Fade Icons When They Swap

A play icon becoming a pause icon, or a copy icon becoming a check, should not hard-swap. Two glyphs of different shapes replacing each other in one frame reads as a glitch. Cross-fade them on three properties at once, and the swap reads as one icon changing rather than two icons trading places.

The entering icon runs scale 0.25 to 1, opacity 0 to 1, and blur 4px to 0px. The exiting icon runs the same three in reverse. Both stack in the same grid cell so neither moves the layout.

.icon-swap {
  display: grid;
  place-items: center;
}
.icon-swap > * {
  grid-area: 1 / 1; /* stack, so the swap never shifts anything */
  transition: scale 200ms ease-out, opacity 200ms ease-out, filter 200ms ease-out;
}
.icon-swap > [data-state="entering"] {
  scale: 1;
  opacity: 1;
  filter: blur(0px);
}
.icon-swap > [data-state="exiting"] {
  scale: 0.25;
  opacity: 0;
  filter: blur(4px);
}

The blur is what sells it. Scale and opacity alone still show two recognisable shapes overlapping mid-transition; blurring the in-flight glyphs means the eye reads a single form resolving. This is the same trick the digit roll uses below.

Item Removal

Removing a list item is a small moment users experience hundreds of times. An instant DOM yank feels abrupt and makes the surrounding layout snap. A two-stage exit — fade the content out, then collapse the space it occupied — turns a destructive action into something that feels intentional and a little joyful.

The pattern (split parent and child responsibilities):

/* Inner container handles the visual exit: blur + fade + scale */
.removing-item .container {
  filter: blur(8px);
  opacity: 0;
  transform: scale(0.92);
  transition:
    filter 280ms ease-in,
    opacity 200ms ease-in,
    transform 280ms ease-in;
}

/* Outer wrapper collapses the space — slightly delayed so the
   content fades first, then the row closes up */
.removing-item {
  width: 0;
  transition: width 320ms cubic-bezier(0.2, 0, 0, 1) 60ms;
}

For vertical lists, animate height (and likely margin/padding) instead of width — same principle, different axis.

Why this works:

  • Two layers, two jobs. The child fades and scales (visual exit); the parent collapses its size (layout exit). Doing both on one element fights itself — the parent shrinks while content tries to fade in the wrong space.
  • Ease-in on exit. Exits should feel like falling away, not landing. ease-in accelerates into nothing, which matches the perception of "gone".
  • Custom cubic-bezier on the collapse. cubic-bezier(0.2, 0, 0, 1) snaps quickly out of rest then settles — the surrounding items glide into place rather than jerking.
  • 60ms delay on the collapse. Lets the content visibly start fading before the layout reflow begins, so the eye reads "this is leaving" before "the list is reordering".

Wiring (React):

Listen for transitionend instead of hard-coding a duration. This stays in sync with prefers-reduced-motion (where CSS durations collapse to ~0ms) and survives future tweaks to the easing/duration without drifting from a magic number.

const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());

const handleRemove = (id: string) => {
  setRemovingIds((prev) => new Set(prev).add(id));
};

const handleTransitionEnd = (id: string, e: React.TransitionEvent<HTMLLIElement>) => {
  // Wait for the wrapper's width (the last property to finish) — ignore
  // bubbled events from the inner container's filter/opacity/transform.
  if (e.target !== e.currentTarget || e.propertyName !== "width") return;
  removeItem(id); // actual mutation / state update
};

return items.map((item) => (
  <li
    key={item.id}
    className={removingIds.has(item.id) ? "removing-item" : ""}
    onTransitionEnd={(e) => handleTransitionEnd(item.id, e)}
  >
    <div className="container">{/* row content */}</div>
  </li>
));

Pitfalls:

  • width: auto doesn't animate. CSS can't transition between intrinsic and explicit sizes. Either give the row an explicit width/max-width/flex-basis at rest, or measure-and-set the start width (el.style.width = el.offsetWidth + "px") on the next frame before adding removing-item. For vertical lists, the same applies to height: auto — or use the modern grid-template-rows: 1fr → 0fr pattern on the parent, which handles intrinsic content without measurement.
  • Don't animate width: 0 on a flex/grid child without also zeroing horizontal padding/margin/gap, or the slot won't fully close.
  • Apply overflow: hidden to the collapsing wrapper so the content clips cleanly while it shrinks.
  • For optimistic UIs, trigger the class first and fire the mutation in parallel — don't wait for the network to start the animation.
  • Honor prefers-reduced-motion: the global override at the top of this doc collapses CSS durations to ~0ms, and the transitionend listener above fires immediately in that case, so the row unmounts in sync. Don't use a fixed setTimeout here — JS won't see the reduced-motion override and the row would sit invisibly in the layout for hundreds of ms before unmounting.

Number Steppers That Feel Awesome

A number stepper that bumps the value once per click forces the user to click twenty times to go from 0 to 20. That is a chore, not a control. Two things turn it into something people actually enjoy holding: reward the hold (the value accelerates while the button is pressed) and animate the digits (each change rolls into place with blur and a gradient mask instead of snapping).

Reward the hold (press-and-repeat with acceleration)

Tapping bumps by one. Holding should keep going, and speed up the longer it is held, so a long run takes a press-and-wait instead of forty taps. The feel that works: a short initial delay before the repeat kicks in (so a deliberate single tap never triggers it), then repeats that start slow and ramp toward fast, optionally jumping the step size (1, then 5, then 10) once the user is clearly travelling a long way.

Key principles:

  • Initial delay before repeat (~300-400ms). A quick tap must stay a single increment. Only a held press past the delay starts the auto-repeat.
  • Accelerate the interval, do not hold it constant. Start each repeat around 150-200ms apart and shrink toward ~40-60ms as the hold continues. A constant interval feels robotic and is still too slow for big jumps.
  • Optionally grow the step. Once the user has held long enough to clearly want a large change, multiply the increment (1 to 5 to 10). Keep the rolling digits readable by not jumping so far the animation has to skip.
  • Stop on pointerup, pointerleave, pointercancel, and blur. Any of these ends the hold; clear the timer in all of them or the value runs away after release.
  • Pointer Events, not separate mouse/touch handlers. One onPointerDown covers mouse, touch, and pen. Capture the pointer on press so the matching pointerup/pointercancel is still delivered to the button when the finger drifts off it, so the hold always ends cleanly instead of getting stuck repeating. (Pointer capture also suppresses pointerleave while held, so the onPointerLeave stop below is the fallback for the un-captured case.)
  • Respect min/max and stop the loop at the bound. When the value hits the limit, cancel the repeat instead of spinning uselessly against the clamp. The consumer example below does this: when clamp returns an unchanged value, it calls stop() to end the hold.
  • Keyboard parity. Arrow keys step; PageUp/PageDown take the larger step; holding an arrow already auto-repeats at the OS level, so do not double-fire. Use a real <input type="number"> or role="spinbutton" with aria-valuenow/aria-valuemin/aria-valuemax so assistive tech reads the live value.

Implementation (accelerating press-and-repeat):

import { useRef, useCallback } from "react";

// Schedule with a self-rescheduling timeout (not setInterval) so each tick
// can shorten the next delay. Ramp from slow to fast over the hold.
function usePressRepeat(step: (multiplier: number) => void) {
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const held = useRef(0);

  const stop = useCallback(() => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = null;
    held.current = 0;
  }, []);

  const start = useCallback(() => {
    stop(); // clear any in-flight timer so a second press can't double-fire
    step(1); // immediate feedback on the first press
    const tick = () => {
      held.current += 1;
      // Grow the step after a sustained hold; ramp the delay from 180ms to 50ms.
      const multiplier = held.current > 24 ? 10 : held.current > 12 ? 5 : 1;
      const delay = Math.max(50, 180 - held.current * 8);
      step(multiplier);
      timer.current = setTimeout(tick, delay);
    };
    // Initial delay so a single tap never auto-repeats.
    timer.current = setTimeout(tick, 350);
  }, [step, stop]);

  return { start, stop };
}
// Reference the hook's own `stop` from the step callback so the repeat ends
// the moment the value reaches a bound (clamp returns it unchanged). The
// callback runs on later ticks, after `stepper` is assigned, so the
// self-reference is safe.
const stepper = usePressRepeat((m) =>
  setValue((v) => {
    const next = clamp(v + m, min, max);
    if (next === v) stepper.stop(); // already at the bound — stop spinning
    return next;
  }),
);
const { start, stop } = stepper;

<button
  aria-label="Increment"
  onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); start(); }}
  onPointerUp={stop}
  onPointerLeave={stop}
  onPointerCancel={stop}
  onBlur={stop}
>
  +
</button>

Rolling digits with blur and a gradient mask

When the number changes, do not hard-swap the text. Roll the affected digits: the old digit slides out as the new one slides in, with a touch of blur on the moving glyphs so the eye reads one continuous roll instead of two stacked frames (this is the same blur-bridges-the-gap trick used for icon swaps and crossfades above). A vertical gradient mask on the digit column fades the top and bottom edges to transparent so glyphs appear and disappear softly at the clip edges instead of getting sliced off with a hard line.

Key principles:

  • Roll per digit, in the direction of change. Counting up rolls upward, counting down rolls downward, so motion direction matches value direction. Only animate the digits that actually changed (the ones place rolls every tick, the tens place rarely), so a fast hold stays legible.
  • Tabular numbers, always. Set font-variant-numeric: tabular-nums (or a font feature) so digits are fixed-width and the column does not jitter sideways as values change. This is non-negotiable for any ticker, timer, or counter.
  • Gradient mask the column, not the page. mask-image: linear-gradient(transparent, #000 20%, #000 80%, transparent) on the digit's overflow container fades the entering/leaving glyph at the edges so there is no abrupt cut. The mask color is irrelevant (alpha is what masks), so this is exempt from the oklch rule.
  • Blur only the in-flight glyphs. A few px of blur during the slide reads as speed; clear it to 0 once settled. Keep blur under 20px (heavy blur is expensive to composite, especially in Safari, see Performance).
  • Snappy, not floaty. A digit roll is a high-frequency micro-interaction during a hold, so keep it short (~150-250ms) with a spring at zero bounce or an ease-out. Do not let the roll lag behind a fast auto-repeat or the displayed number falls behind the real value.
  • Clip the column. The overflow container needs overflow: hidden so the outgoing and incoming digits are only visible inside the row, with the gradient mask softening where they cross the boundary.

Implementation (single rolling digit with blur, masked column):

import { motion, AnimatePresence } from "motion/react";

// One digit slot: the column is masked + clipped; each glyph rolls in/out blurred.
// `direction` lives in variant functions and is fed through AnimatePresence's
// `custom` prop, so the EXIT direction is resolved at animation time — not
// captured at render. Without this, reversing mid-hold makes the departing
// digit exit using its stale render-time direction and both glyphs slide the
// same way.
const rollVariants = {
  enter: (direction: 1 | -1) => ({
    y: `${direction * 100}%`,
    filter: "blur(6px)",
    opacity: 0,
  }),
  center: { y: "0%", filter: "blur(0px)", opacity: 1 },
  exit: (direction: 1 | -1) => ({
    y: `${direction * -100}%`,
    filter: "blur(6px)",
    opacity: 0,
  }),
};

function RollingDigit({ digit, direction }: { digit: string; direction: 1 | -1 }) {
  return (
    <span
      className="relative inline-block overflow-hidden tabular-nums"
      style={{
        // Soft top/bottom edges instead of a hard clip line.
        maskImage:
          "linear-gradient(to bottom, transparent, #000 25%, #000 75%, transparent)",
        WebkitMaskImage:
          "linear-gradient(to bottom, transparent, #000 25%, #000 75%, transparent)",
      }}
    >
      <AnimatePresence mode="popLayout" initial={false} custom={direction}>
        <motion.span
          key={digit}
          custom={direction}
          className="block"
          variants={rollVariants}
          initial="enter"
          animate="center"
          exit="exit"
          transition={{ type: "spring", stiffness: 500, damping: 40, mass: 0.6 }}
        >
          {digit}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

Translate the glyph by percentage (100%), not a pixel height, so the roll stays correct at any font size without re-measuring (see Transform Techniques). Render one RollingDigit per place and pass direction from the sign of the change so up-counts roll up and down-counts roll down. Pass direction through AnimatePresence's custom prop (and the matching custom on the child) rather than inlining it into initial/exit objects: the exiting digit is the previous render's node, so an inline exit freezes the old direction and a fast reversal sends both glyphs the same way. The custom prop re-resolves the variant at exit time, keeping motion aligned with the value's direction.

Accessibility and reduced motion:

  • The rolling animation is decorative. Under prefers-reduced-motion drop the roll, blur, and mask and swap the digit in place (the global override at the top of this doc collapses the durations; for the JS-driven AnimatePresence roll, also gate initial/exit so the digit does not fly). The number is the information; the roll is not.
  • Expose the live value to assistive tech via the underlying <input type="number"> / role="spinbutton", not the animated glyph spans, which should be aria-hidden so a screen reader reads "7", not each rolling frame.

Mastering AnimatePresence

When an element leaves the DOM, it's gone—there's no way to animate something that no longer exists. Motion's AnimatePresence fixes this by keeping departing elements mounted long enough to animate out, then removing them.

Reading Presence State with useIsPresent

Sometimes a component needs to know it's exiting—to change its appearance, disable interactions, or trigger side effects.

import { AnimatePresence, motion, useIsPresent } from "motion/react";

function Card() {
  const isPresent = useIsPresent();

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.9 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.9 }}
      transition={{ duration: 0.4, ease: [0.19, 1, 0.22, 1] }}
    >
      {isPresent ? "Present" : "Exiting..."}
    </motion.div>
  );
}

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <AnimatePresence>
      {show && <Card />}
    </AnimatePresence>
  );
}

Key principles:

  • The hook returns true while mounted normally, false during exit animation
  • Use this to disable buttons while exiting, switch visual states, or trigger cleanup
  • Important: useIsPresent must be called from a child component of AnimatePresence—you cannot inline it where you conditionally render

Manual Exit Control with usePresence

For async cleanup, external animation libraries, or coordinating with systems outside React, use usePresence which returns both presence state and a safeToRemove callback.

import { AnimatePresence, motion, usePresence } from "motion/react";
import { useEffect, useState } from "react";

function Notification() {
  const [isPresent, safeToRemove] = usePresence();

  useEffect(() => {
    if (!isPresent) {
      // Do async cleanup, then signal removal
      const timer = setTimeout(() => {
        safeToRemove();
      }, 500);
      return () => clearTimeout(timer);
    }
  }, [isPresent, safeToRemove]);

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.95 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.95 }}
      transition={{ type: "spring", stiffness: 500, damping: 30 }}
    >
      {isPresent ? "Notification" : "Cleaning up..."}
    </motion.div>
  );
}

Use cases:

  • Save draft content before a modal closes
  • Wait for a network request to complete
  • Hand control to GSAP or other animation libraries for complex sequences

The exit animation starts immediately while your async work runs in parallel. The element unmounts when both the animation finishes AND safeToRemove is called.

Nested Exits with propagate

When a parent AnimatePresence removes children, nested exit animations don't fire by default—the parent wins. Use the propagate prop to enable coordinated parent-child exits.

import { AnimatePresence, motion } from "motion/react";

const items = ["A", "B", "C"];

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <AnimatePresence>
      {show && (
        <motion.div
          key="card"
          initial={{ opacity: 0, scale: 0.95 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={{ opacity: 0, scale: 0.95 }}
          transition={{ duration: 0.8, ease: [0.19, 1, 0.22, 1] }}
        >
          {/* Inner AnimatePresence with propagate */}
          <AnimatePresence propagate>
            {items.map((item) => (
              <motion.div
                key={item}
                initial={{ opacity: 0, filter: "blur(10px)" }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{ opacity: 0, filter: "blur(10px)" }}
                transition={{ duration: 0.5 }}
              >
                {item}
              </motion.div>
            ))}
          </AnimatePresence>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

AnimatePresence Modes

The mode prop controls timing between entering and exiting elements:

sync (default):

  • Entering and exiting elements animate simultaneously
  • Useful for crossfades or when both should be visible at once
  • Handle layout carefully since both elements exist at the same time

wait:

  • Exit completes before enter begins
  • More elegant sequential transitions
  • Note: Total duration is roughly doubled since animations run sequentially

popLayout:

  • Removes exiting elements from document flow immediately
  • Exiting elements become absolutely positioned
  • Surrounding content reflows instantly
  • Ideal for list reordering, morphing layouts, and animated width containers

When to Use AnimatePresence vs CSS

CSS covers simple enter and exit transitions without JavaScript: @starting-style supplies the entry state, and transition-behavior: allow-discrete keeps the element around long enough to animate out. Both are covered in component-architecture.md, including the constraints that make them silently do nothing. However, AnimatePresence is still necessary for:

  • Reading presence state (useIsPresent)
  • Manual exit control (usePresence + safeToRemove)
  • Directional animations based on navigation
  • Coordinated nested exits (propagate)
  • Complex mode timing (wait, popLayout)

Rule of thumb: Use CSS for simple enter/exit animations on elements that don't need to know they're leaving. Use AnimatePresence when components need presence awareness or coordinated timing.

Expanding to Intrinsic Height Without Measurement

Animating a panel open to whatever height its content happens to be is the one case CSS cannot do naively: height: auto is not an animatable value, so you cannot transition from 0 to auto. The old workaround was to measure the content height in JavaScript (Framer Motion's animate={{ height: "auto" }}, or a manual el.scrollHeight) and animate to that pixel value. Measuring forces a synchronous reflow on every open and stutters when content or fonts shift.

The modern fix is a CSS grid track. A parent grid with a single row transitions grid-template-rows from 0fr (collapsed) to 1fr (its content's natural height). The browser interpolates the fractional track for you: no measurement, no JavaScript.

<div
  className={cn(
    "grid overflow-hidden motion-safe:transition-[grid-template-rows] motion-safe:duration-300 motion-safe:ease-[cubic-bezier(.23,1,.32,1)]",
    open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
  )}
>
  <div className="min-h-0 overflow-hidden">{children}</div>
</div>

Two structural rules make it work:

  • The grid container owns overflow-hidden. While the track collapses, content must clip rather than spill past the shrinking row.
  • The inner child needs min-h-0. Grid items default to min-height: auto, which refuses to shrink below content size and breaks the collapse. min-h-0 lets the row actually reach 0fr.

Declare the transition under motion-safe: rather than cancelling it with motion-reduce:transition-none. The variants map onto the two values of the preference, so motion-safe: applies only on a positive no-preference match, while the cancelling form leaves the transition running in any browser that cannot evaluate the query. The row still switches between 0fr and 1fr either way, so reduced-motion users get an instant toggle rather than a panel that never opens.

The accessibility caveat: always-mounted content

The grid trick only hides content visually. If the collapsed panel stays in the DOM (which it must, to animate), its links, buttons, and inputs are still tabbable and its text is still announced by screen readers. The old mount/unmount approach hid them for free; the CSS approach makes hiding your responsibility.

Two ways to restore correct behavior:

  • JavaScript-toggled wrappers: use inert. When you already drive the open state with a boolean, add inert={!open}. It removes the subtree from both tab order and the accessibility tree in one attribute.

    <div inert={!open} className={cn("grid overflow-hidden ...", open ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
    
  • Attribute-driven wrappers: flip visibility. When the open state lives in a data-* attribute (for example a Radix primitive with forceMount), inert has no boolean to bind to. Toggle visibility instead, which also drops the subtree from tab order and the a11y tree. Include visibility in the transition list so the content stays visible through the close animation and only disappears once it finishes collapsing:

    // visibility transitions discretely: it stays `visible` for the full
    // duration while closing, then flips to `hidden` at the very end.
    className="invisible grid grid-rows-[0fr] overflow-hidden transition-[grid-template-rows,opacity,visibility] ... data-[state=open]:visible data-[state=open]:grid-rows-[1fr]"
    

Prefer the primitive's native height variable when it has one

Reach for the grid trick when you own the markup. If you are already on a primitive that publishes a content-height variable, use that instead: it is simpler and it keeps the primitive in charge of mounting and accessibility. Radix Accordion exposes --radix-accordion-content-height; the shadcn default animates it with keyframes and never sets forceMount, so collapsed content is unmounted and the a11y caveat above never arises:

@keyframes accordion-down { from { height: 0 } to { height: var(--radix-accordion-content-height) } }
@keyframes accordion-up   { from { height: var(--radix-accordion-content-height) } to { height: 0 } }
<AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">

Only drop to forceMount plus the grid-or-visibility approach when you specifically need the content to stay mounted: preserving form state, running an enter animation the primitive will not, or matching a sibling non-primitive toggle's exact transition. When you do, you own the accessibility handling.

Direction-Aware Menu Transitions

When a dropdown or panel switches between siblings, the outgoing panel should exit in the direction the user moved away from and the incoming panel should enter from the direction the user moved toward. Without direction awareness both panels cross-fade or slide the same way, which feels disconnected from the cursor's path.

Radix NavigationMenu exposes this directly via a data-motion attribute it sets on NavigationMenu.Content:

  • from-start — entering from the left
  • from-end — entering from the right
  • to-start — exiting to the left
  • to-end — exiting to the right

Wire four keyframes to the four states. Use fixed translateX distances rather than percentages so displacement stays consistent regardless of panel width — variable-width siblings should still slide in by the same visual amount.

@keyframes enterFromRight { from { opacity: 0; transform: translateX(200px); } to { opacity: 1; transform: translateX(0); } }
@keyframes enterFromLeft  { from { opacity: 0; transform: translateX(-200px); } to { opacity: 1; transform: translateX(0); } }
@keyframes exitToRight    { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(200px); } }
@keyframes exitToLeft     { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-200px); } }

[data-motion="from-start"] { animation-name: enterFromLeft; }
[data-motion="from-end"]   { animation-name: enterFromRight; }
[data-motion="to-start"]   { animation-name: exitToLeft; }
[data-motion="to-end"]     { animation-name: exitToRight; }

The primitive observes which sibling becomes active and sets the attribute. The animation layer only names the keyframes. Hint will-change: transform, opacity on the content element so the browser promotes it to its own compositing layer before the animation starts, avoiding a first-frame stutter.

Viewport-Driven Size Transitions

A shared viewport that hosts panels of different sizes must animate its own width and height when the active panel changes. Snapping between sizes looks broken at any animation speed.

The pattern: a single container reads the active panel's dimensions from CSS custom properties the primitive publishes, then transitions width and height. Radix NavigationMenu.Viewport exposes:

  • --radix-navigation-menu-viewport-width
  • --radix-navigation-menu-viewport-height
.viewport {
  width: var(--radix-navigation-menu-viewport-width);
  height: var(--radix-navigation-menu-viewport-height);
  transition: width 250ms ease, height 250ms ease;
  will-change: transform, width, height;
}

Pair with scale-and-fade keyframes keyed off data-state:

@keyframes scaleIn  { from { opacity: 0; scale: 0.96 } to { opacity: 1; scale: 1; } }
@keyframes scaleOut {                                    to { opacity: 0; scale: 0.96; } }

.viewport { transform-origin: top center; }
.viewport[data-state="open"]   { animation: scaleIn  200ms ease; }
.viewport[data-state="closed"] { animation: scaleOut 200ms ease; }

The scale animates the open/close beat; the width/height transition animates the resize between siblings. They run independently and compose cleanly.

Shared Layout for Sliding Selections

When a hover or selection highlight moves between list rows, the highlight should slide between positions rather than disappear and reappear. Two renders of the same element with the same layoutId let Motion treat them as one element moving — it reads both positions and interpolates the transform itself, no measurement math required.

{items.map((item) => (
  <div key={item.id} onMouseEnter={() => setHoveredId(item.id)}>
    {hoveredId === item.id && (
      <motion.div layoutId={`${panel}-highlight`} className="absolute inset-0 bg-muted" />
    )}
    {item.label}
  </div>
))}

Two details that bite:

  • Scope the layoutId. If two unrelated lists share "highlight", the highlight will fly across the screen when focus moves between them. Prefix with the parent's identity: `${panelValue}-highlight`.
  • Render conditionally, not always. The point is that only one instance of the element exists at a time; Motion handles the cross-fade between mount sites. Keeping a single always-mounted node and translating it yourself defeats the abstraction.

One Transition for a Whole Subtree

When several elements in a component should share a transition, setting it on each one invites drift: someone tunes one value and the group stops moving together. Set it once on the parent instead.

<MotionConfig transition={{ type: "spring", duration: 0.5, bounce: 0.2 }}>
  {children}
</MotionConfig>

Every descendant inherits it, and a child that passes its own transition still wins where it genuinely needs to differ. Wrap the component, not the page: a transition tuned for a compact control will feel wrong on a full-screen surface.

Steering a Shared Layout Animation

A shared layout animation computes its own path between the two positions. Timing is yours to set, with transition={{ layout: { duration: 0.3 } }} on the component being animated to, but the path itself is not: you cannot bend it into an arc or hold it partway by adding offsets to the shared element.

Animate the parent instead. Children inherit the parent's movement on top of the layout animation Motion is already running, which gives you back the control you wanted without touching the shared element.

<motion.div animate={{ y: 73 }} transition={{ delay: 0.13 }}>
  {items}
</motion.div>

Use it to add an arc, a settle, or a short hold to a transition that would otherwise travel in a straight line. Keep the parent's movement small; it compounds with the layout animation rather than replacing it.

Exit Before Enter for Rich States

Crossfading works when one side is visually simple. Morphing between two states that both carry content shows the user two competing layouts at once, and the result reads as a glitch rather than a transition.

<AnimatePresence mode="wait">
  <motion.div
    key={view}
    initial={{ opacity: 0 }}
    animate={{ opacity: 1 }}
    exit={{ opacity: 0 }}
  >
    {view === "timer" ? <TimerView /> : <RingView />}
  </motion.div>
</AnimatePresence>

Wait mode holds the incoming state until the outgoing one has finished leaving, so only one is ever on screen.

  • The direct child of AnimatePresence must be a motion element with an exit prop, otherwise there is no exit animation to wait for
  • The key must change when the view changes, or Motion reads it as an update rather than a swap
  • The transition now costs both durations, so shorten each to roughly half of what you would use for a crossfade
  • Prefer popLayout when items are leaving a list and the survivors should close the gap; prefer wait mode when one view replaces another

When both states are simple, a plain crossfade is smoother and cheaper. Reserve wait mode for content-rich swaps.

Indicator That Tracks the Active Trigger

A small arrow, underline, or dot under a tab strip that slides to point at whichever item is active. The primitive (Radix NavigationMenu.Indicator) measures the active trigger and sets transform and width on itself — no refs or DOM measurement in user code. The animation lives entirely in CSS.

.indicator {
  position: absolute;
  top: calc(100% + 2px);
  transition:
    transform 0.35s cubic-bezier(0.16, 1, 0.3, 1),
    width     0.25s ease,
    opacity   0.2s  ease-out;
}

.indicator[data-state="hidden"]  { opacity: 0; }
.indicator[data-state="visible"] { opacity: 1; }

cubic-bezier(0.16, 1, 0.3, 1) is ease-out-expo — a long, soft tail that reads as a spring without the cost of an actual spring solver. The parent list needs position: relative so the absolutely-positioned indicator stays contained. A child SVG centered inside the indicator stays centered on the active trigger automatically because the primitive sets the indicator's width to match.

Scroll-Linked Fade Effect

A CSS-only scroll-linked fade effect that creates dynamic top and bottom gradients based on scroll position.

Browser Support

animation-timeline: scroll() is not in every engine. Gate it behind the @supports block below rather than tracking version numbers, and check current support before treating the effect as load-bearing.

The code uses @supports for progressive enhancement, so it degrades gracefully in unsupported browsers.

The Code

@supports (animation-timeline: scroll()) {
  @property --ft {
    syntax: "<length>";
    inherits: false;
    initial-value: 0px;
  }
  @property --fb {
    syntax: "<length>";
    inherits: false;
    initial-value: 40px;
  }

  .scroll-fade-y {
    mask-image: linear-gradient(
      to bottom,
      transparent 0,
      #000 var(--ft),
      #000 calc(100% - var(--fb)),
      transparent 100%
    );
    mask-size: 100% 100%;
    mask-repeat: no-repeat;
    animation: t 1 linear both, b 1 linear both;
    animation-timeline: scroll(self), scroll(self);
    animation-range: 0% 12%, 88% 100%;
  }

  @keyframes t {
    from {
      --ft: 0px;
    }
    to {
      --ft: 40px;
    }
  }
  @keyframes b {
    from {
      --fb: 40px;
    }
    to {
      --fb: 0px;
    }
  }
}

SVG Transforms

Cross-Browser SVG Transforms

  • Safari historically had bugs with transform-origin on SVG elements
  • Apply CSS transforms/animations to <g> wrapper elements, not directly to SVG shapes
  • Set transform-box: fill-box and transform-origin: center on the wrapper

Example:

<svg viewBox="0 0 100 100">
  <g class="animated-group">
    <circle cx="50" cy="50" r="40" />
  </g>
</svg>
.animated-group {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 0.3s ease-out;
}

.animated-group:hover {
  transform: scale(1.1);
}

Why this matters:

  • Prevents transform-origin miscalculation in Safari
  • Ensures consistent behavior across browsers
  • Avoids animations that work in Chrome but break in Safari

Performance

  • Stick to opacity and transforms when possible. Example: Animate using transform instead of top, left, etc. when trying to move an element.
  • Do not animate drag gestures using CSS variables.
  • Do not animate blur values higher than 20px.
  • Use will-change to optimize your animation, but use it only for: transform, opacity, clipPath, filter.
  • When using Motion/Framer Motion use transform instead of x or y if you need animations to be hardware accelerated.
  • Prefer CSS transitions over JavaScript when possible.

Accessibility

  • If the animation moves an element with transform, the blanket duration sweep will not stop it. Keep the transform inside the no-preference gate and leave the settled position in the ungated rule.
  • Remove animations entirely for frequently-used interactions (10+ times daily).

Checklist

  • Default to ease-out for most animations
  • Custom curves start from the matching preset and exaggerate it, rather than from arbitrary handle positions
  • A curve that teleports is fixed with more duration, not a milder curve
  • Keep animations under 300ms for ordinary feedback (200-300ms typical); long durations only on strongly front-loaded curves for singular arrivals
  • linear on continuous rotation, ease-in-out on alternating oscillation, ease where no situational curve applies
  • No curve-tuning effort spent on pure fades
  • Bounce comes from linear(), never from a cubic-bezier() with an output above 1
  • Shared easing tokens cover most motion; bespoke curves reserved for a few splashy moments
  • Use springs for user-input-driven motion
  • Make animations interruptible (cancel on close)
  • Disable hover animations on touch devices
  • Set transform-origin to match trigger position
  • Never animate from scale(0) (use 0.9+)
  • Motion is declared inside @media (prefers-reduced-motion: no-preference), and the ungated rules hold the settled state
  • Only animate interactions that provide feedback or clarify state changes
  • Stick to opacity and transforms for performance
  • Use parent/child pattern to prevent hover flicker
  • Animate item removal in two stages: fade the content, then collapse the space
  • Number steppers: reward the hold with accelerating press-and-repeat (initial delay, ramping interval, growing step), and roll the changed digits with blur + a gradient-masked column over tabular-nums; drop the roll under reduced motion
  • Split-text enter: stagger by word/char on hero headings only; full string in aria-label, spans aria-hidden, honor prefers-reduced-motion
  • Translate by percentage (translateY(100%)) for off-screen parking, not hardcoded pixels
  • Use scale() for press feedback so children scale as one piece
  • Reach for clip-path insets for reveals, tab color swaps, hold-to-confirm, and comparison sliders
  • Dismiss gestures on flick velocity, not just drag distance
  • Gesture motion starts from the current presentation value and retargets without restarting
  • Release animations receive pointer velocity and project to the right resting point
  • Apply rising friction past drag boundaries instead of a hard stop
  • Capture the pointer and ignore extra touch points once a drag starts
  • Drag-to-reorder: show a handle indicator, animate scale/rotation/shadow on the lift, reveal the landing spot with an empty-space cutout, and give it a keyboard path (Arrow Up / Down on the handle)
  • Write transform directly on the dragged element, never via a parent CSS variable
  • Blur a crossfade that looks off so it reads as one shape, not two overlapping
  • Drive stretch from velocity (not a fixed keyframe) for moving objects: stretch along travel, squash perpendicular, low directional blur, clamped, dropped under reduced motion
  • Review motion in slow motion, frame by frame, on real devices, and again with fresh eyes
  • Match easing, duration, and bounce to the component's personality

Transform Techniques

Stick to transform for any movement, scale, or rotation. It runs on the GPU, skips layout and paint, and stays smooth even while the page is busy painting something else.

Translate by percentage, not pixels. A percentage in translate() is read against the element's own size, so translateY(100%) slides an element exactly its own height off-screen no matter how tall it is. This is how off-screen drawers and toasts park themselves before sliding in, and it survives content changes without re-measuring.

.drawer-hidden { transform: translateY(100%); }  /* one full height down, any size */
.toast-hidden  { transform: translateY(-100%); } /* one full height up */

scale() carries the children. Unlike width/height, scaling an element also scales its text, icons, and padding together. That is exactly what you want for press feedback (the whole button shrinks as one piece), so reach for scale() rather than resizing the box.

3D depth without JavaScript. rotateX() / rotateY() on a parent set to transform-style: preserve-3d give real depth: flips, orbits, tilt-on-hover cards. Keep perspective modest so the depth reads without looking warped.

.card-stage { transform-style: preserve-3d; perspective: 800px; }
.card:hover { transform: rotateY(8deg) rotateX(4deg); }

Always pair a transform with a matching transform-origin so the motion grows from the right anchor (see Transform Origin above).

Velocity-Driven Stretch (Speed Stretch)

Real objects don't hold a rigid shape while they move. They deform in the direction they're travelling and the faster they go, the more they stretch. Borrowing this from the classic squash-and-stretch principle makes even a plain translation feel like it has weight and momentum, instead of a rectangle teleporting from A to B.

The idea: drive the deformation from the element's velocity, not its position. When the thing is at rest, it sits at its natural shape (scaleX: 1). As it accelerates, stretch it along its axis of travel and squash it slightly on the perpendicular axis (volume looks roughly preserved). When it decelerates back to rest, it snaps back to its natural shape. Pair the stretch with a touch of motion blur in the same direction so the eye reads speed rather than a wobble.

Why velocity, not a fixed keyframe: a hardcoded stretch keyframe applies the same deformation regardless of how far or how fast the element actually moved. Reading live velocity means a small nudge barely deforms while a long fling stretches hard, which is exactly how real motion looks. It also stays correct when the animation is interrupted or redirected mid-flight.

Implementation with Motion:

import { motion, useMotionValue, useVelocity, useTransform } from "motion/react";

function SpeedStretch({ target }: { target: number }) {
  const x = useMotionValue(0);
  const velocity = useVelocity(x); // px/s, signed by direction

  // Stretch along travel, squash perpendicular. Clamp so a fast fling
  // can't tear the element apart.
  const scaleX = useTransform(velocity, [-2000, 0, 2000], [1.4, 1, 1.4], {
    clamp: true,
  });
  const scaleY = useTransform(velocity, [-2000, 0, 2000], [0.8, 1, 0.8], {
    clamp: true,
  });

  // Directional blur, kept well under 20px (see Performance).
  const blurPx = useTransform(velocity, [-2000, 0, 2000], [4, 0, 4], {
    clamp: true,
  });
  const filter = useTransform(blurPx, (b) => `blur(${b}px)`);

  return (
    <motion.div
      animate={{ x: target }}
      transition={{ type: "spring", stiffness: 700, damping: 40 }}
      style={{ x, scaleX, scaleY, filter, transformOrigin: "center" }}
    />
  );
}

Key principles:

  • Hoist every useTransform to the top of the component. Calling useTransform(...) inline inside the style prop is a hook in a render expression — it breaks the rules of hooks. Compute the motion values once, then reference them.
  • The input value must actually move for useVelocity to read anything. Bind x to the same MotionValue you animate (animate={{ x: target }} + style={{ x }}), or drive it from a drag/scroll. A bare useMotionValue(0) that nothing writes to always reports zero velocity.
  • Stretch one axis, squash the other. Scaling only the travel axis makes the element visibly gain area at speed. A small inverse squash on the perpendicular axis keeps the apparent volume constant and reads as more physical.
  • clamp: true on the transforms. Velocity can spike far past your range on a hard fling; without clamping the element stretches grotesquely. Clamp both ends.
  • Blur in the direction of travel, low values only. A few pixels sells the speed. Keep it under 20px (heavy blur is expensive to composite, especially in Safari — see Performance) and clear it back to 0 at rest.
  • Pair with a spring, not a fixed easing. Velocity-driven stretch shines when the underlying motion itself carries momentum and can be interrupted. A fixed-duration tween produces a flatter velocity curve and a duller effect.

Respect reduced motion. This is decorative deformation, not information — drop it entirely under prefers-reduced-motion. Gate the stretch/blur transforms behind useReducedMotion() and fall back to plain scaleX: 1 / no filter so the element still moves to its target, just without the deform.

import { useReducedMotion } from "motion/react";

const reduce = useReducedMotion();
const scaleX = useTransform(velocity, [-2000, 0, 2000], reduce ? [1, 1, 1] : [1.4, 1, 1.4], { clamp: true });

Reserve this for objects whose movement is the point — a card flung across a board, a draggable token, a hero element that travels on a deliberate beat. Applying it to high-frequency or tiny movements just adds noise.

clip-path Techniques

clip-path: inset(top right bottom left) is one of the most underused animation tools. Each side value eats inward from that edge, and because it is just a clip it animates cheaply. Animate the inset values to reveal, wipe, or progress-fill content.

/* Reveal from one side */
.overlay { clip-path: inset(0 100% 0 0); transition: clip-path 200ms ease-out; }
.overlay.shown { clip-path: inset(0 0 0 0); }

Tabs with a seamless color swap. Color transitions between an active and inactive tab never quite line up if you tween each tab's color independently. Instead, stack two copies of the tab strip: a normal layer and an "active-styled" layer (different background and text color). Clip the active layer so only the current tab shows through, and animate that clip as the selection moves. The color flip becomes a single clean wipe rather than a dozen separate fades.

Hold-to-confirm and scroll reveals. A destructive button can fill a colored overlay from inset(0 100% 0 0) to inset(0 0 0 0) over a deliberate couple of seconds while held, then snap back fast on release (see Asymmetric timing below). For scroll reveals, start hidden from the bottom with inset(0 0 100% 0) and clear it to inset(0 0 0 0) once the element enters the viewport. Comparison sliders overlay two images and drive one image's inset from the drag position, no extra DOM needed.

For programmatic clip-path animation that stays hardware-accelerated and interruptible without a library, drive it through the Web Animations API (element.animate([...], { duration, fill: "forwards", easing })).

Gesture & Drag Interactions

Touch and pointer dragging is where motion has to feel physical, because the user is literally pushing the pixels. A few rules make a drag feel like a real object instead of a value being assigned.

Dismiss on velocity, not just distance. Requiring a fixed drag distance feels stiff. Also measure how fast the user flicked, speed = abs(distance) / elapsedMs, and dismiss on a quick flick even if it never crossed the distance threshold. A short, fast swipe should be enough to throw a toast or drawer away.

const elapsed = Date.now() - dragStart;
const velocity = Math.abs(swipe) / elapsed;
if (Math.abs(swipe) >= DISMISS_DISTANCE || velocity > 0.11) dismiss();

Resist at the edges, never wall off. When the user drags past a natural boundary (pulling a drawer further open than it goes), do not freeze it. Apply rising friction so each extra pixel of drag moves the element less. Real objects slow before they stop; a hard wall feels broken. This is the same feel as iOS rubber-banding.

Controlled boundary resistance and snap points

Not every pixel should feel equally movable. Free movement can track the pointer one-to-one, but important limits should push back. That change in resistance tells the user where the interaction's meaningful states are and makes the surface feel physical instead of like a number being assigned.

Build a constrained drag from five layers:

  1. Constraints define the legal range. Use dragConstraints to describe where the object may travel.
  2. Low elasticity adds controlled give. dragElastic={0.08} allows only a small amount of movement past the constraint, so the edge resists without becoming a hard wall. Treat 0.08 as a starting point, then tune it on a real pointer and touchscreen.
  3. Disable free inertia for controlled states. dragMomentum={false} prevents an automatic throw after release. Use this for drawers, sheets, scrubbers, and other reversible controls where overshoot would feel careless. Keep momentum for interactions where throwing is the intent, such as swipe-to-dismiss.
  4. Snap to semantic resting positions. Define named points such as center, open, or content-driven sheet detents. On release, choose between them from position and velocity; do not leave the element between states.
  5. Settle with a spring. Spring from the visible release position to the chosen snap point. A critically damped or slightly overdamped spring adds weight without an unrelated bounce.
import { animate, motion, useMotionValue } from "motion/react";

const SNAP_POINTS = {
  center: 0,
  open: 320,
} as const;

const RELEASE_SPRING = {
  type: "spring",
  stiffness: 500,
  damping: 45,
  mass: 0.8,
} as const;

function ConstrainedDrawer() {
  const x = useMotionValue<number>(SNAP_POINTS.center);

  return (
    <motion.aside
      style={{ x }}
      drag="x"
      dragConstraints={{
        left: SNAP_POINTS.center,
        right: SNAP_POINTS.open,
      }}
      dragElastic={0.08}
      dragMomentum={false}
      onDragStart={() => x.stop()}
      onDragEnd={(_, info) => {
        // Velocity influences the decision, but not a free inertial throw.
        const projectedX = x.get() + info.velocity.x * 0.12;
        const midpoint = (SNAP_POINTS.center + SNAP_POINTS.open) / 2;
        const target =
          projectedX < midpoint ? SNAP_POINTS.center : SNAP_POINTS.open;

        animate(x, target, RELEASE_SPRING);
      }}
    />
  );
}

The coordinates and projection factor are component-specific; the structure is the principle. Constrain the range, let the boundary resist, select an intentional resting state, then spring there. Reverse the coordinates for a drawer that opens in the opposite direction.

Dragging must not be the only way to change the state. Pair the gesture with an equivalent button or keyboard action and expose the current state to assistive technology. Under prefers-reduced-motion, preserve the same snap decision but settle instantly instead of playing the release spring.

Capture the pointer once dragging starts so the gesture keeps tracking even when the finger or cursor leaves the element's bounds. Without it, a fast drag that outruns the element drops the gesture mid-motion.

Ignore extra touch points after the drag begins. If a second finger lands, the element can jump to it. Guard the start (if (isDragging) return) so the original contact owns the gesture until release.

Drive the dragged element's transform directly, not a parent CSS variable. Setting a custom property on a container forces a style recalc for every child each frame. Write element.style.transform on the moving element itself so only it updates.

Drag-to-Reorder: make it intuitive and alive

Reordering a list by dragging is one of the few moments where the user is directly manipulating the layout. Make the interaction feel intuitive and alive instead of a row silently jumping to a new index.

  • Show a handle indicator. Give each row an explicit grab affordance (a grip glyph) and make it the only thing that starts a drag, so the rest of the row stays clickable and the user knows exactly where to grab. Pair it with grab / grabbing cursors.
  • Animate the lift, not just the move. While a row is held, animate its scale, rotation, and shadow during the transition so it reads as picked up off the surface: scale up slightly, add a small tilt, and raise an elevated shadow, then settle them all back on drop. A flat translate feels like a value being reassigned; the lift makes it feel like a physical object.
  • Show where it will land with an empty-space cutout. As the held row moves, open a dashed empty-space cutout at the target slot and let the other rows spring around it, so the user can see the landing position before releasing. The cutout is the promise; the spring reflow is how the list keeps the user oriented (the same Spatial Consistency idea in the glossary below).
  • Give it a keyboard path, not just a pointer one. Drag is a mouse/touch gesture, so a draggable list is inaccessible until the same reorder works from the keyboard (WCAG 2.1.1, keyboard operable). Make the handle focusable and move the row with Arrow Up / Arrow Down (announce the new position), so the reorder never depends on a pointer.

Drive the lift and reflow with springs (this is user-input motion that must survive interruption), keep the shadow/scale/rotation interpolation smooth rather than snapping, and honor prefers-reduced-motion by collapsing the lift to an instant state change. The pointer rules above still apply: capture the pointer, ignore extra touch points, and write the transform on the dragged row itself.

Masking Imperfect Crossfades

When two states swap in the same spot and the crossfade looks "off" even after you have tried different easings and durations, the problem is usually that the eye is catching two distinct objects overlapping. A small filter: blur(2px) applied during the transition blends them into one perceived shape, so the swap reads as a single morph instead of two things trading places. Clear the blur once the new state has settled. Keep blur values low (well under 20px); heavy blur is expensive to composite, especially in Safari. This pairs well with a subtle scale on the outgoing/incoming content for an extra-clean state change.

Debugging Animations

Feel is hard to judge at full speed. Build a habit of inspecting motion deliberately:

  • Slow it down. Temporarily multiply the duration by 2x to 5x, or use the browser DevTools animation inspector to scrub playback. At reduced speed you can see whether colors crossfade cleanly, whether the easing stalls or snaps, whether the transform-origin is correct, and whether opacity and transform stay in step.
  • Step frame by frame. The Chrome DevTools Animations panel lets you walk an animation one frame at a time, which exposes timing drift between properties that move together but should not.
  • Test gestures on real hardware. Drawers, swipe-to-dismiss, and momentum feel different under a real finger than under a mouse or simulator. Open the dev server on a physical phone over the network and use remote devtools for the real thing.
  • Look again the next day. Imperfections invisible while you are building surface with fresh eyes. Re-review motion you shipped yesterday before calling it done.

Match Motion to the Component's Mood

Animation values are not universal; they should fit the personality of what is moving. A playful surface can carry a touch more bounce and a slightly slower, softer curve; a dense professional dashboard should feel crisp and fast with minimal flourish. Cohesion is what makes motion feel intentional: the easing, the duration, the visual design, and even the copy should all read as one voice. When in doubt for a list of items entering and leaving, the opacity-and-height pairing is mostly trial and error: there is no exact formula, so nudge the values until it feels right, then leave it alone.

Give Moving Elements a Sense of Weight

Pixels weigh nothing, so weight is a decision you make. Assigning it makes movement legible: the user reads how substantial a thing is from how it moves, before they read any label.

Use it in two ways.

Physical weight. When an element stands for something with real substance, linear movement reads as wrong. A copied image trailing the cursor, a file being dragged, a card being thrown: give these a spring with enough mass that they lag slightly behind the input and settle rather than stop. Match the amount to what the object represents. Design tool layers should feel weightless, because the user is repositioning them hundreds of times a session and any lag reads as lag.

Consequence as weight. Actions that are hard to undo should feel heavier to perform. Hold to confirm is the clearest version: the friction is the point, and it scales with the severity of what is about to happen. A frequently repeated action should feel light; a destructive one should not. This is not a replacement for a confirmation dialog, it is another tool for the same job.

Weight also comes from how often the interaction happens. Something performed fifty times a day should shed every millisecond of resistance. Something performed once should be allowed to take its time.

Keep Layers From Colliding Mid-Transition

When a container resizes and its contents change at the same time, the outgoing and incoming content will overlap somewhere in the middle. Overlapping text is the single most common reason a well tuned morph still looks wrong. It clutters, it breaks the rhythm, and the user cannot read either layer.

The tempting fix is to wait for the old layer to finish leaving before the new one arrives. Do not. That delays the response to input, and lag is worse than clutter.

Fix it without adding delay:

  • Blur the crossfade. A 1px to 2px filter: blur() on the transitioning layers blends the glyphs into each other so the eye stops resolving two separate objects
  • Stagger the entrance per character or per item. A delay: index * 0.01 gives visual interest and spreads the overlap across time, while the first few characters still appear immediately so nothing feels late
  • Speed up the exit. Roughly double the stiffness on the exit transition compared to the entrance. Stale content should clear faster than new content arrives
const animate = {
  opacity: 1,
  filter: "blur(0px)",
  transition: { ...transition, delay: index * 0.01 },
};

const exit = {
  ...initial,
  transition: { ...transition, stiffness: transition.stiffness * 2 },
};

The principle underneath: respond to input immediately, then manage the overlap with blur and stagger rather than with waiting.

Animate the Sized Child, Not the Container

When a surface resizes to fit changing content, animating the outer element looks broken even though the width transition itself is smooth. The contents re-centre on every frame while the box is still moving, so everything inside jitters.

Animate the wrapper around the content that is actually changing size. The parent then resizes as a consequence, and its siblings get pushed aside smoothly.

// Wrong: the footer animates, its children re-centre every frame.
<motion.footer animate={{ width: bounds.width }}>
  <div ref={ref}>
    <Logo />
    <p>{active}</p>
  </div>
  <Menu />
</motion.footer>

// Right: the sized child animates, the footer follows.
<footer>
  <motion.div animate={{ width: bounds.width }}>
    <div ref={ref}>
      <Logo />
      <p>{active}</p>
    </div>
  </motion.div>
  <Menu />
</footer>

Measure the inner content with a resize observer and animate the wrapper to that width. If a morph feels jarring and the easing is not the cause, check which element you attached the animation to before touching the spring values.

Animation Vocabulary

The motion-naming glossary moved to its own doc so it loads without being truncated off the tail of this one. Load it with get-ui-principle({ topic: "animation-vocabulary" }) to map a loose description ("make it move") to a precise term ("scale in with overshoot", "stagger", "rubber-banding", "pop in"). Naming the motion makes AI codegen deterministic instead of a guess that varies run to run.

Creating v0 Gift Card

The gift reveal uses a physical metaphor rather than a generic confirmation screen. Perspective and depth establish a card-like surface. A restrained gradient, holographic light, and a cursor-following spotlight make the object react to the viewer.

The scratch layer is best understood as a mask whose hidden regions are painted into a canvas. Different brush shapes vary the texture, and a small set of sound samples avoids obvious repetition. The memorable quality comes from agreement between visual, gestural, and auditory feedback—not from any effect in isolation.

The effect should still respect reduced-motion and sound preferences, work without precise pointer input, and provide a direct accessible reveal path.

Interpolation & Map Range

Many dynamic interfaces translate a value from one coordinate system into another: scroll distance into header size, pointer position into tilt, drag distance into resistance, or time into color.

Normalize the input, optionally clamp it, transform the normalized progress with an easing function, then interpolate the output.

type RangeOptions = {
  clamp?: boolean;
  ease?: (progress: number) => number;
};

export function mapRange(
  value: number,
  input: readonly [number, number],
  output: readonly [number, number],
  options: RangeOptions = {},
) {
  const [inputStart, inputEnd] = input;
  const [outputStart, outputEnd] = output;
  const rawProgress = (value - inputStart) / (inputEnd - inputStart);
  const boundedProgress = options.clamp
    ? Math.min(1, Math.max(0, rawProgress))
    : rawProgress;
  const progress = options.ease?.(boundedProgress) ?? boundedProgress;

  return outputStart + (outputEnd - outputStart) * progress;
}

const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3;
const headerHeight = mapRange(scrollY, [0, 240], [88, 52], {
  clamp: true,
  ease: easeOutCubic,
});

Mapping without clamping is useful when values should extrapolate. Clamping is safer for bounded properties such as opacity. Rubber-band motion uses a related idea but should approach a limit asymptotically, so additional drag produces progressively less visible travel.

Wave Functions

A sine wave produces a smooth repeating value between -1 and 1. Three parameters provide most of its expressive range:

  • amplitude controls the strength of the output;
  • frequency controls how often it repeats;
  • phase shifts where the cycle begins.

Map the output into position, rotation, scale, opacity, hue, or another visual property. Offset the phase across repeated elements to create coherent propagation rather than identical motion.

type Wave = {
  amplitude: number;
  frequency: number;
  phase?: number;
};

export function sampleWave(time: number, wave: Wave) {
  const phase = wave.phase ?? 0;
  return wave.amplitude * Math.sin(time * wave.frequency + phase);
}

export function layeredWave(time: number) {
  const base = sampleWave(time, { amplitude: 1, frequency: 1 });
  const detail = sampleWave(time, {
    amplitude: 0.28,
    frequency: 2.4,
    phase: Math.PI / 3,
  });

  return base + detail;
}

const normalized = (layeredWave(time) + 1.28) / 2.56;
const rotation = mapRange(normalized, [0, 1], [-8, 8]);

Combining waves at different frequencies creates richer motion and generative shapes. Keep the lower-frequency wave dominant so the result retains a readable rhythm. Sine-driven loops return naturally to their starting value, avoiding the seam that often appears in hand-authored looping keyframes.

Masks

A mask changes visibility without changing the underlying content. clip-path creates a hard geometric boundary; mask-image uses alpha or luminance and is suited to soft transitions and textured reveals.

.scroll-edge-fade {
  mask-image: linear-gradient(
    to bottom,
    transparent,
    oklch(0% 0 0) 24px,
    oklch(0% 0 0) calc(100% - 24px),
    transparent
  );
}

.spotlight-reveal {
  --spot-x: 50%;
  --spot-y: 50%;
  mask-image: radial-gradient(
    circle 96px at var(--spot-x) var(--spot-y),
    oklch(0% 0 0) 0%,
    oklch(0% 0 0 / 0.82) 55%,
    transparent 100%
  );
}

.angled-window {
  clip-path: polygon(8% 0, 100% 0, 92% 100%, 0 100%);
}

Masks are often more theme-independent than placing a background-colored overlay on top. Combined with clipping and translation, a mask can also make an object appear to enter a slot or pass behind a surface without constructing a complex 3D scene.

Animations

Motion should explain state, relationship, or cause. When it cannot answer what changed or where something went, it is probably decoration.

Useful patterns:

  • stagger a repeated group just enough to reveal sequence and structure;
  • replace fast-changing content simultaneously when stagger would feel sluggish;
  • move objects along an arc when the motion represents travel rather than resizing;
  • derive movement from the event or origin that caused it;
  • make every direct-manipulation transition interruptible and reversible;
  • keep press feedback immediate, then let the larger state transition follow.

An arc can be produced by interpolating the main axis linearly and adding a parabolic lift on the perpendicular axis:

type Point = { x: number; y: number };

export function pointOnArc(
  start: Point,
  end: Point,
  progress: number,
  lift: number,
): Point {
  const x = start.x + (end.x - start.x) * progress;
  const linearY = start.y + (end.y - start.y) * progress;
  const arcOffset = 4 * lift * progress * (1 - progress);

  return { x, y: linearY - arcOffset };
}

Test motion with reduced-motion preferences, keyboard activation, rapid repeated input, interruption midway, and slower hardware. A polished animation that blocks the next action is still a poor interaction.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "animation" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems