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.
beUI motion system
A practical guide to deciding when something should move, choosing the right token, and shipping motion that stays fast, coherent, and accessible.
Decision framework
The best animation decision is often made before touching a duration or spring value.
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.
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.
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.
Reduced motion keeps useful opacity and color feedback while removing travel, scale, and parallax.
Accessibility is a motion state, not an afterthought.
Motion tokens
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
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
Each recipe connects a purpose to a production token, a reduced-motion state, and a concrete failure mode.
A press should acknowledge input before the action finishes. Keep the scale small and the response immediate.
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>Let the icon imitate its real action. A bell swings from its hinge; a download arrow drops toward its tray.
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>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.
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 }}
/>Keep the same surface visible while its footprint changes. Move the shape first, then introduce its label.
<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>For small view changes, let the old content leave faster than the new content arrives. Keep travel to a few pixels.
<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
Do not remove every transition. Keep feedback that helps comprehension and remove movement that can cause discomfort.
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)",
};