beUI motion system

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.

01

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.

02

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.

03

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.

04

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.

EASE_OUT[0.16, 1, 0.3, 1]

Entrances and exits respond immediately, then settle quietly.

EASE_IN_OUT[0.77, 0, 0.175, 1]

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.

Press feedback100–160msImmediate and physical
Tooltip / popover125–200msQuick, origin-aware
Dropdown / select150–250msResponsive, no waiting
Modal / drawer200–500msEnough time to explain space
Marketing demoFlexibleClarity matters more than speed

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.

Feedback

Press feedback

A press should acknowledge input before the action finishes. Keep the scale small and the response immediate.

PurposeConfirms that the interface heard the user.
AvoidLarge scale changes, slow rebounds, or a spring on every child.
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>
Meaning

Semantic icon motion

Let the icon imitate its real action. A bell swings from its hinge; a download arrow drops toward its tray.

PurposeReinforces what the action does without adding another label.
AvoidThe same generic bounce on every icon, or motion on touch hover.
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>
Entrance

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.

PurposePrevents new content from appearing as a jarring visual cut.
AvoidHalf-second UI entrances or a long stagger across every small child.
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 }}
/>
Movement

Layout continuity

Keep the same surface visible while its footprint changes. Move the shape first, then introduce its label.

PurposePreserves object identity while a compact control expands.
AvoidDirect width tweens or text appearing before the surface makes room.
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>
State change

Content swap

For small view changes, let the old content leave faster than the new content arrives. Keep travel to a few pixels.

Live component view
PurposeClarifies that content changed while the surrounding context stayed put.
AvoidLarge page-like transitions for tabs, filters, or frequently changed views.
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)",
};