Skip to content

Animate React interfaces with Motion the right way

When to animate, which spring to reach for, how to keep motion interruptible, and how to build a reduced motion branch that still gives feedback.

On this page
  1. Decide whether it should move
  2. Animate transform and opacity
  3. Use springs you can reason about
  4. Keep motion interruptible
  5. Shared highlights with layoutId
  6. Lists that close the gap
  7. Build the reduced motion branch
  8. Reduced motion in CSS
  9. A short checklist

Decide whether it should move

Every animation should answer one question: what just changed, and where did it go? A panel slides from the edge it lives on. A number counts toward its new value. A removed row leaves and the list closes the gap. If you cannot name the change, the animation is decoration, and decoration gets tiring the tenth time.

How often people see it matters as much as what it shows:

  • Many times a minute, like typing, keyboard navigation or a command menu: no animation, or under 100 ms.
  • Many times a day, like hover, tabs and toggles: short and quick, around 150 to 250 ms.
  • Occasionally, like dialogs, drawers and toasts: a clear enter and a faster exit.
  • Rarely, like onboarding or a first success: this is where a little extra is welcome.
Terminal
npm install motion // Every import comes from "motion/react" (the package formerly called Framer Motion).import { motion, AnimatePresence, useReducedMotion } from "motion/react";

Animate transform and opacity

Browsers can move, scale and fade an element without recalculating the layout of the page. Width, height, margins and top force layout on every frame, and that is where dropped frames come from.

example.tsx
// Moves on the compositor: cheap, smooth under load.<motion.div animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 8 }} /> // Triggers layout on every frame: avoid for anything large or frequent.<motion.div animate={{ height: 240, marginTop: 16 }} />

When size really has to change, like an accordion opening, animate it once on a spring and let the content fade with it. When position changes because of layout, use Motion's layout prop: it measures both positions and animates a transform between them.

Use springs you can reason about

Classic spring settings like stiffness and damping are hard to picture. Motion also accepts visualDuration, roughly how long the movement looks like it takes, and bounce, from 0 for no overshoot to 1 for a lot. That makes springs as easy to tune as a duration.

Arc ships three springs as shared tokens, so every component moves with the same character:

registry/motion-tokens.ts
// registry/motion-tokens.ts (installed with any Arc component)export const motionTokens = {  spring: {    /** Presses, toggles, thumbs, and small indicators. */    snappy: { type: "spring", visualDuration: 0.26, bounce: 0.12 },    /** Panels, height changes, and layout shifts. Never overshoots. */    smooth: { type: "spring", visualDuration: 0.4, bounce: 0 },    /** Shape morphs, shared highlights, and width that follows content. */    morph: { type: "spring", visualDuration: 0.42, bounce: 0.16 },  },  // ...durations, easing curves, stagger and blur steps} as const;
thumb.tsx
import { motion } from "motion/react";import { motionTokens } from "@/lib/motion-tokens"; export function Thumb({ on }: { on: boolean }) {  return (    <motion.span      className="thumb"      initial={false}      animate={{ x: on ? 18 : 0 }}      transition={motionTokens.spring.snappy}    />  );}

Keep bounce small. Around 0.1 feels alive on a toggle; 0 is right for panels and anything that carries text, which should never wobble.

visualDuration 0.26s, bounce 0.12. Click the track twice quickly to interrupt it.

A real Motion spring driven by Arc's tokens. Pick a preset, then click the track. The color change stays when reduced motion is on.

Keep motion interruptible

People change their minds halfway through. A spring starts from wherever the element is, at whatever speed it is moving, and heads to the new target. Click the track above twice quickly: the thumb turns around smoothly instead of finishing the first trip.

CSS keyframe animations restart from the beginning when you swap them, which shows up as a jump. For anything a person can trigger repeatedly, like toggles, toasts and drag, use a spring or a CSS transition, which also retargets from the current value.

Shared highlights with layoutId

A selection highlight that slides from the old tab to the new one tells people where their choice went. Render the highlight only inside the selected item and give it a layoutId. Motion sees the same id leave one place and appear in another, and animates between them.

tabs.tsx
import { LayoutGroup, motion } from "motion/react"; export function Tabs({ tabs, value, onChange }: TabsProps) {  return (    <LayoutGroup id="project-tabs">      {tabs.map(tab => (        <button key={tab.value} aria-pressed={value === tab.value} onClick={() => onChange(tab.value)}>          {value === tab.value && (            <motion.span              layoutId="selection"              className="selection"              transition={motionTokens.spring.morph}            />          )}          <span className="label">{tab.label}</span>        </button>      ))}    </LayoutGroup>  );}

Wrap each instance in a LayoutGroup with its own id, otherwise two tab bars on the same page share one highlight. Arc's segmented control and tabs are built this way.

Lists that close the gap

When something leaves a list, the rows below should slide up to fill its place. Without that, the list jumps and people lose their spot. AnimatePresence keeps the leaving row mounted long enough to fade out, and mode="popLayout" takes it out of the flow straight away, so the remaining rows start moving on the same frame.

notifications.tsx
<ul>  <AnimatePresence mode="popLayout" initial={false}>    {items.map(item => (      <motion.li        key={item.id}        layout="position"        exit={{ opacity: 0, transition: { duration: 0.16 } }}        transition={{ layout: motionTokens.spring.smooth }}      >        {item.title}      </motion.li>    ))}  </AnimatePresence></ul>
  • Marcus merged Checkout redesign2 min ago
  • Ava commented on Billing settings18 min ago
  • Emma invited Sofia to the workspace1 hour ago
  • Usage is at 80 percent of your seat limit3 hours ago
Dismiss a few rows. The leaving row fades while the others close the gap on the smooth spring.

Build the reduced motion branch

Some people get dizzy or nauseous from movement on screen, and they turn on the reduced motion setting in their operating system. Reduced motion does not mean no feedback. Remove travel, zoom, parallax and looping. Keep fades, color changes and the final state, so it is still obvious that something happened.

Motion's useReducedMotion reads the setting. On the server it cannot know the answer, so a page rendered on the server and hydrated on the client needs care: report reduced motion only after hydration, or the first client render will not match the HTML.

use-hydrated-reduced-motion.ts
"use client"; import { useSyncExternalStore } from "react";import { useReducedMotion } from "motion/react"; const subscribe = () => () => {}; /** * The server cannot know the user's setting, so it renders with motion. * Report "reduced" only after hydration, so the first client render matches. */export function useHydratedReducedMotion() {  const prefersReduced = useReducedMotion();  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);  return hydrated && Boolean(prefersReduced);}
example.tsx
const reduced = useHydratedReducedMotion(); <motion.div  initial={reduced ? false : { opacity: 0, y: 12 }}  animate={{ opacity: 1, y: 0 }}  transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}/> // A drawer still needs to appear and disappear. Keep a short fade, drop the travel.const drawer = reduced  ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.12 } }  : { initial: { x: "100%" }, animate: { x: 0 }, transition: motionTokens.spring.smooth };

Reduced motion in CSS

Not everything needs JavaScript. Hover states, simple transitions and looping decoration can live in CSS, with a media query for the reduced branch.

toast.module.css
.toast {  transition: transform 240ms var(--ease-standard), opacity 240ms var(--ease-standard);} @media (prefers-reduced-motion: reduce) {  .toast {    /* Keep the fade so the change is still visible, drop the movement. */    transition: opacity 120ms linear;  }  .marquee { animation: none; }}

A short checklist

  • Can you name what each animation explains?
  • Is anything triggered from the keyboard animated for longer than about 100 ms?
  • Are you animating transform and opacity, not layout properties?
  • Do exits run faster than entrances?
  • Can every animation be interrupted without a jump?
  • With reduced motion on, is every state change still visible?

Building something data heavy? The SaaS dashboard guide applies these rules to counters, tables and timelines.

More guides