Motion guide
Motion that explains, not distracts
A practical guide to deciding when something should move, choosing the right token, and shipping motion that stays fast, coherent, and accessible.
Decision framework
Four questions before motion
The best animation decision is often made before touching a duration or spring value.
Check frequency
Repeated actions should feel nearly instant. Save expressive motion for moments users see occasionally.
100× a day: no choreography. Rare moments: more delight is allowed.
Name the purpose
Motion should explain space, confirm input, show state, or soften a change. Decoration alone is not enough.
If you cannot explain why it moves, remove the movement.
Choose the physics
Use ease-out for entrances, ease-in-out for movement, linear for progress, and springs for gestures.
The curve should match what the object is doing, not personal taste.
Design the fallback
Reduced motion keeps useful opacity and color feedback while removing travel, scale, and parallax.
Accessibility is a motion state, not an afterthought.
Motion tokens
Use one language everywhere
beUI keeps deliberate motion in shared tokens. Choose by purpose so components feel related without moving identically.
Compare the curves
Same distance and duration. Different jobs.
Entrances and exits respond immediately, then settle quietly.
Objects already on screen accelerate and decelerate naturally.
SPRING_PRESS
Fast, weighted feedback for buttons and other pressable surfaces.
SPRING_LAYOUT
Shared surfaces and indicators that need continuous spatial movement.
Timing
Fast enough to feel immediate
Duration depends on size, distance, and frequency. These ranges are starting points, not targets to hit mechanically.
Under 300ms is the default for interface motion. Longer motion belongs to explanatory demos, deliberate gestures, and large spatial changes.
Recipes
Patterns you can copy
Each recipe connects a purpose to a production token, a reduced-motion state, and a concrete failure mode.
Press feedback
A press should acknowledge input before the action finishes. Keep the scale small and the response immediate.
View recipe+
import { motion, useReducedMotion } from "motion/react";
import { SPRING_PRESS } from "@/lib/ease";
const reduce = useReducedMotion();
<motion.button
whileTap={reduce ? undefined : { scale: 0.97 }}
transition={SPRING_PRESS}
>
Continue
</motion.button>Semantic icon motion
Let the icon imitate its real action. A bell swings from its hinge; a download arrow drops toward its tray.
View recipe+
const reduce = useReducedMotion();
const canHover = useHoverCapable();
<motion.span
style={{ transformOrigin: "top center" }}
animate={active && canHover && !reduce
? { rotate: [0, 12, -8, 4, 0] }
: { rotate: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT }}
>
<Bell />
</motion.span>Content reveal
Reveal one meaningful surface with a short lift and restrained blur. The motion should finish before it becomes the focus.
New surface
Ready before focus.
View recipe+
const reduce = useReducedMotion();
<motion.div
initial={{
opacity: 0,
transform: reduce ? "none" : "translateY(8px)",
filter: reduce ? "none" : "blur(4px)",
}}
animate={{
opacity: 1,
transform: "translateY(0px)",
filter: "blur(0px)",
}}
transition={{ duration: 0.22, ease: EASE_OUT }}
/>Layout continuity
Keep the same surface visible while its footprint changes. Move the shape first, then introduce its label.
View recipe+
<motion.div layout transition={CONTINUITY_SPRING}>
<motion.button layout transition={CONTINUITY_SPRING}>
<motion.span layout="position">
<PanelTopOpen />
</motion.span>
<AnimatePresence mode="popLayout" initial={false}>
{expanded ? (
<motion.span
layout
variants={CONTINUITY_LABEL}
initial="hidden"
animate="visible"
exit="exit"
transition={CONTINUITY_SPRING}
>
Open panel
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
</motion.div>Content swap
For small view changes, let the old content leave faster than the new content arrives. Keep travel to a few pixels.
View recipe+
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={tab}
initial={{ opacity: 0, transform: "translateY(4px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
exit={{
opacity: 0,
transform: "translateY(-4px)",
transition: { duration: 0.12, ease: EASE_OUT },
}}
transition={{ duration: 0.18, ease: EASE_OUT }}
/>
</AnimatePresence>Accessibility
Reduced motion is a designed state
Do not remove every transition. Keep feedback that helps comprehension and remove movement that can cause discomfort.
Keep meaning. Remove travel.
Preserve opacity, color, and instant state changes. Drop parallax, large transforms, repeated scale, and spring overshoot.
const reduce = useReducedMotion();
const hidden = {
opacity: 0,
transform: reduce ? "none" : "translateY(8px)",
};
const visible = {
opacity: 1,
transform: "translateY(0px)",
};