Motion
CDS 4 has a single motion scale: seven durations, a set of easing curves and two loops. It is the same for every theme (cds, b2b, promo, luma). You can use it four ways: ready-made Vue transitions, a Sass module, CSS variables and the motion JS object.
Take steps from the scale instead of literals like
0.2s ease-in-out. Your animations then run at the same pace as CDS components, and changing a value in the scale reaches every place at once.
Duration scale
Press «Запустить» (Run): the markers travel the same distance, each in the time of its step.
| Name | Value | Used for |
|---|---|---|
instant | 50ms | press |
very-fast | 100ms | entering hover, popover and checkmark disappearing |
fast | 200ms | leaving hover, color and selection changes, appearance |
normal | 300ms | resizing a large block, modal and drawer appearance |
slow | 400ms | expanding a large element |
very-slow | 500ms | backdrop dimming |
spring | 300ms | the time a spring takes to settle |
Loops sit outside the scale: the scale answers a user action, while a loop runs on its own. A pulse lasts 2s, a ripple 1.5s.
Easing curves
The same duration on different curves: enter rushes in and brakes, exit accelerates and leaves, standard starts softly and stops softly. Switch the duration to compare the curves over a short and a long run.
| Name | Value | Used for |
|---|---|---|
standard | cubic-bezier(0.2, 0, 0, 1) | default: in-place changes and movement |
enter | cubic-bezier(0.05, 0.7, 0.1, 1) | appearance |
exit | cubic-bezier(0.3, 0, 1, 1) | leaving only |
spring | linear(…), M3 fast-spatial spring | a small object appearing with a bounce |
loop | cubic-bezier(0.37, 0, 0.63, 1) | the pulse loop |
ripple | cubic-bezier(0, 0, 0.2, 1) | the ripple loop |
linear | linear | timer: the tick step equals the duration |
The spring bounce belongs to scale and position only. Use the spring on transform, not on opacity or background-color: color and opacity never bounce.
What to choose
| Case | Duration | Curve |
|---|---|---|
| press | instant | standard |
| entering hover | very-fast | standard |
| leaving hover, selection change, color | fast | standard |
| popover, dropdown, toast appearing | fast | enter |
| popover, checkmark leaving | very-fast | exit |
| resizing a large block | normal | standard |
| modal, drawer: appear / leave | normal/fast | enter / exit |
| pulse / ripple | 2s / 1.5s | loop / ripple |
Ready-made transitions
Transitions are wrapper components over Vue <Transition> and <TransitionGroup>: put an element with v-if or v-show inside, no classes or styles needed. Pick a transition and hide or show the card:
| Transition | What it animates | Time and curve | When to use |
|---|---|---|---|
CdsTransitionFade | opacity | fast enter / very-fast exit | in-place appear and leave: hints, captions |
CdsTransitionSlideY | 8px vertical shift and opacity | fast enter / very-fast exit | small blocks, dropdown content |
CdsTransitionSlideX | 15px horizontal shift and opacity | fast enter / very-fast exit | horizontal content changes |
CdsTransitionSlideRight | full-width shift to the right; the leaving block leaves the flow | normal standard | paging forward: calendar, carousel |
CdsTransitionSlideLeft | full-width shift to the left; the leaving block leaves the flow | normal standard | paging back |
CdsTransitionJumpRight | 15px shift to the right and opacity; the leaving block leaves the flow | normal standard | a value that "moves" forward: counters, captions |
CdsTransitionJumpLeft | 15px shift to the left and opacity; the leaving block leaves the flow | normal standard | the same in the other direction |
CdsTransitionScale | scale from zero and opacity; leaving is a fade only | fast enter / very-fast exit | pop-up menus, tags |
CdsTransitionExpand | height (JS): vertical expand and collapse | fast standard | expandable panels, accordion |
CdsTransitionExpandX | width (JS): the same horizontally | fast standard | side panels, collapsible columns |
CdsTransitionPresence | fade, then collapse of height, spacing and border | leave very-fast exit + fast standard; appear fast standard + fast enter | a block leaves the flow and neighbors must move smoothly; this is how a CdsTree branch expands |
CdsTransitionModalBottom | slides in from the bottom of the screen | normal enter / fast exit | a panel or window coming from the bottom |
CdsTransitionModalTop | slides in from the top of the screen | normal enter / fast exit | a panel or window coming from the top |
CdsModalTransition | the modal unfolds from a source element (target prop) | normal enter / fast exit; content normal×2 / fast×2 on standard | a modal that "flies out" of a button or card |
Props depend on the kind of transition:
- CSS transitions (
Fade,Slide*,Jump*,Scale,Modal*):mode,group(a list viaTransitionGroup),disabled(no animation),hideOnLeave(hides the leaving element withdisplay: none),leaveAbsolute(takes the element out of the flow while it leaves) andorigin(the point scale starts from). - JS transitions (
Expand,ExpandX):mode,disabled,group,hideOnLeave. CdsTransitionPresence:appear,group,disabled— see below.CdsModalTransition: a singletargetprop — the element or[x, y]coordinates the modal unfolds from.
You can play with every prop in Storybook, in the «Песочница: переходы» (Transitions playground) story.
What the transitions share:
- A leaving element taken out of the flow (
SlideRight,SlideLeft,Jump*and any CSS transition withleaveAbsolute) does not take clicks: a click will not land on something that is already leaving. - Under
prefers-reduced-motion, shifts and scale are replaced with a fade.ExpandandExpandXchange size instantly under reduced motion. JS transitions (Expand,ExpandX,CdsModalTransition) read the mode at mount, anddisabledfollows it by default.
CdsTransitionPresence: a block leaving the flow
When a notification, alert or list row leaves the page, a fade alone is not enough: a gap stays behind and the neighbors jump into it. Collapsing the height at the same time as the fade shows squashed text. CdsTransitionPresence does it in two phases — add and remove a few invoices:
- Leave. A
very-fastfade on theexitcurve, then afastcollapse onstandard. Vertical spacing and the border collapse together with the height, so no gap remains. - Enter runs in reverse: a
fastexpand onstandard, then afastfade-in onenter. - Under
prefers-reduced-motiononly the fade stays; the space closes and opens instantly.
This is how CdsTree branches expand and CdsNotification leaves. Set the spacing between list blocks with margin, not with gap on the container: margin collapses together with the block, while gap disappears with a jump at the end of the leave.
| Prop | What it does |
|---|---|
appear | play the transition on the initial render |
group | use TransitionGroup — for a list of elements |
disabled | disable the animation: elements appear and disappear at once |
Other Transition and TransitionGroup attributes (for example, tag on a list) are passed through. @after-leave fires after both phases — a handy point to emit a close event:
<CdsTransitionPresence @after-leave="emit('closed')">
<div v-if="visible" class="alert">Saved</div>
</CdsTransitionPresence>Hook listeners (@enter, @after-leave and others) receive only the element.
Pulse and ripple
A pulse signals "happening now": the dot breathes with opacity. A ripple signals "something new": a ring under the dot grows twice its size and fades. Both loops are turned on where needed and do not run under prefers-reduced-motion. A ready-made example of a signaling loop is CdsStatus with the indicator and pulse props: the status dot breathes while the process is going. The pulse is endless, so under WCAG 2.2.2 turn it on only while the signal is relevant.
In SCSS the same loops come from mixins, which declare their keyframes themselves:
@use '@central-design-system/components/styles/motion';
.status-dot {
@include motion.pulse;
}
.status-dot__ring {
@include motion.ripple;
}Reduced motion
Under prefers-reduced-motion: reduce, shifts, scale and springs turn off. Opacity and color stay: state feedback does not disappear, and a color change does not cause vestibular discomfort.
Loops fall into two classes:
- Decorative or signaling — pulse, ripple and the
CdsSkeletonLoadershimmer. Turns off: the status reads without animation, and endless blinking is exactly what such a user asks to get rid of. - Progress indicator —
CdsLoader, indeterminateCdsProgressBar. Keeps running: a frozen spinner reads as a hang. WCAG 2.2.2 treats such motion as essential.
Write your own progress indicator without spatial. DS components follow the same policy: the skeleton shimmer is off under reduced motion, while CdsLoader and the indeterminate CdsProgressBar keep running.
To check the behavior without changing system settings, use Chrome DevTools: menu ⋮ → More tools → Rendering → Emulate CSS media feature prefers-reduced-motion → reduce. The demos on this page switch right away.
If you enable the legacy reset (
$legacy-reset: true), it does not impose the reduced-motion policy: the CDS 3 markup compatibility reset does not mute animations.
Reference
The values in the Sass module, the CSS variables and motion match — a test checks it.
Sass module
The module is a separate entry and pulls in nothing else, so it fits any SCSS in your project. The functions return plain values, not var(): you can do math with them in Sass (add up delays, sum phases).
@use '@central-design-system/components/styles/motion';| Call | Returns |
|---|---|
motion.duration(<name>) | the step duration, for example 200ms |
motion.easing(<name>) | the curve, for example cubic-bezier(0.2, 0, 0, 1) |
motion.transition(<properties>, <duration>, <curve>, <delay>) | a transition list: every property with one step |
motion.interactive-transition(<properties>, <state>) | the interactive step for the rest, hover or active state |
A name that is not in the scale is a compile error listing the valid names. The property list of transition is required: the module never writes transition: all.
State change. transition takes space-separated properties, a duration and a curve (standard by default). Several calls join with commas:
.tooltip {
transition: motion.transition(opacity transform, fast, enter);
&.is-leaving {
transition: motion.transition(opacity, very-fast, exit);
}
}
.handle {
transition:
motion.transition(left, fast),
motion.transition(right, fast, $delay: very-fast);
}Hover and press. @include motion.interactive(<properties>) spreads the steps over the states: leaving hover 200ms, entering 100ms, press 50ms. The browser takes the transition of the state the element moves into, so the steps live on the states. If the hover is on an ancestor and the transition on a descendant, take the step by hand:
.chip {
@include motion.interactive(background-color border-color color);
}
.card:hover .arrow {
transition: motion.interactive-transition(transform, hover);
}Scale and position. Put everything that moves or scales into spatial: under reduced motion it turns off, while color and opacity stay. If the DS itself sets a shift that would otherwise remain, pair it with reduced — rules for reduced-motion users only:
.badge {
@include motion.spatial {
animation: pop motion.duration(spring) motion.easing(spring);
}
}
.drawer-content {
transform: translateX(-16px);
@include motion.reduced {
transform: none;
}
}CSS variables
The same values sit on :root — for plain CSS, inline styles and Tailwind. The names follow the scale: --cds-motion-duration-<name>, --cds-motion-easing-<name>, --cds-motion-loop-<name> (names from the tables above). Switching the theme does not change them.
.toast {
transition: opacity var(--cds-motion-duration-fast) var(--cds-motion-easing-enter);
}The motion JS object
When a script drives the motion — a timer, the Web Animations API, a delay calculation — take the scale from motion. Durations and loops are numbers in milliseconds, curves are CSS strings.
import { motion } from '@central-design-system/components';
motion.durations.fast; // 200
motion.easings.enter; // 'cubic-bezier(0.05, 0.7, 0.1, 1)'
motion.loops.pulse; // 2000
el.animate([{ opacity: 0 }, { opacity: 1 }], {
duration: motion.durations.fast,
easing: motion.easings.enter
});