Skip to main content

Motion

Motion in this library is an enhancement layered on top of an interface that already works without it. Every component reaches every state with its durations set to zero, and nothing depends on an animation finishing.

Two scales, not one​

Motion splits by what it animates, not by how long it lasts.

ScaleAnimatesTokens
SpatialPosition and size: translate, scale, transform, inline-size, block-size--rc-motion-spatial-duration-*, --rc-motion-spatial-easing-*
EffectsAppearance: opacity, color, background-color, box-shadow--rc-motion-effects-duration-*, --rc-motion-effects-easing-*

Each has fast, default, and slow variants.

Keeping them apart matters for two independent reasons. Material's motion system resolves spatial motion with more energy than effects motion, so applying a spatial curve to opacity makes color and alpha visibly wobble. And the accessibility case below turns on the same distinction: spatial motion is what causes harm, effects motion is not. A theme that mixes the two cannot respond to a reduced-motion preference precisely.

Pick the scale from the property, every time:

/* Position and size: spatial. */
.panel {
transition: translate var(--rc-motion-spatial-duration-default)
var(--rc-motion-spatial-easing-default);
}

/* Opacity and color: effects. */
.panel::after {
transition: opacity var(--rc-motion-effects-duration-fast) var(--rc-motion-effects-easing-fast);
}

Duration​

Match the duration to the size of the change. A control changing state should be quicker than a surface arriving on screen.

ChangeDuration
Micro: ripple, tick, indicator dot50 to 100 ms
Component state: button, chip, row150 to 200 ms
Container: menu, card expand250 to 300 ms
Screen element: dialog, sheet350 to 400 ms

Anything past about 400 ms reads as waiting rather than as feedback.

Direction​

An element arriving and the same element leaving should not share a curve.

MomentEasingFeel
ArrivingDecelerateEnters quickly, settles gently
LeavingAccelerateStarts slowly, exits quickly
Changing in placeSymmetricEven on both ends

Exits are usually quicker than entrances: the user has already decided, and waiting for a leaving element is waiting for nothing.

Accessibility​

Some people get dizzy or nauseated from motion on screen. The trigger is movement across the visual field, especially large or parallax movement. A cross-fade is not the same thing, and treating it as though it were costs users the cues that make an interface legible.

WCAG 2.3.3 requires that motion from interactions can be turned off unless it is essential. The operating system preference is the signal:

@media (prefers-reduced-motion: reduce) {
/* Remove movement. */
.panel {
transition-property: opacity;
}
}

The guidance this library follows:

  • Remove spatial motion. Slides, scale, parallax, and anything travelling across the viewport. This is the part that causes harm.
  • Keep effects motion, shortened. A fade of 50 to 100 ms still signals that something changed without moving anything. Removing every transition makes state changes read as glitches, which helps nobody.
  • Never remove the state change itself, only its animation. A control that animates to a state must reach that state instantly instead.
  • Do not animate anything that flashes. Nothing above three flashes per second, whatever the preference is set to.
  • Do not gate meaning on motion. If an animation is the only thing that communicates something, that something is undocumented for anyone who does not see it.

The split makes this expressible: a reduced-motion block zeroes the spatial scale and shortens the effects scale, and every component follows without knowing the preference exists.

Performance​

The browser animates transform and opacity on the compositor, without recalculating layout or repainting. Everything else costs more, and some properties cost a great deal.

  • Prefer transform and opacity. Move with translate, resize with scale, fade with opacity.
  • Avoid animating layout properties. width, height, top, left, margin, and padding force layout on every frame, for the element and often its siblings.
  • Use will-change sparingly and briefly. It reserves compositor memory. Applied to many elements, or left on permanently, it costs more than it saves.
  • Animate few elements at once. Ten elements moving together is one composited layer if they share a parent, and ten layers if they do not.
  • Measure rather than assume. A transition that feels fine on a desktop can miss frames on a mid-range phone, which is where it matters.

scripts/audit-component-architecture.mjs enforces the layout-property rule: it parses every transition/transition-property shorthand and @keyframes body across component and theme CSS, and errors on any layout property (box geometry, not paint) that isn't in ANIMATED_LAYOUT_PROPERTY_BUDGETS with a recorded justification. transition: all is a hard error everywhere, with no budget able to scope it. A few components carry a deliberate exception because they resize to content the theme can't predict from CSS alone; an indicator tracking the selected item's box (rc-navigation-bar, rc-navigation-rail), a disclosure panel's real open height, a switch thumb whose box changes with the control's size, and a progress fill tracking literal percentage.

That static check only covers CSS. rc-bottom-sheet and rc-splitter animate layout with the Web Animations API instead of CSS, calling .animate() directly with a measured pixel top/height or width/height pair to snap to a released drag position. That's outside the audit's reach by construction, and it's also the right call here: both animate to a geometry only known at drag-release time, which isn't expressible as a transform without either distorting the dragged content or requiring the same measurement work anyway.

Degrading gracefully​

Motion is the first thing to go, and nothing should break when it does.

  • Zero duration is a supported state, not a fallback. Every component is fully usable with all motion removed, which is also what the Windows 3.1 theme ships, where both scales are zero because the period had no motion at all.
  • Do not block on transition events. A transitionend listener that never fires because the duration is zero, or because the element was removed mid-transition, is a hang. Treat those events as optional.
  • Newer features degrade on their own. @starting-style, discrete transitions, and transition-behavior: allow-discrete simply do not apply where they are unsupported, which lands on the instant change. That is the correct outcome, so no fallback is needed.
  • A theme may decline motion entirely by setting both scales to zero. Nothing in any component requires a non-zero duration.