Skip to content

Animations

Animation utilities run a keyframe animation on an element: a loading pulse, a spinner, a notification ping, an entrance fade or an error shake. Each class is a preset of an animation mixin; the mixins take a map of options for duration, distance, easing and repetition, and write their @keyframes once per stylesheet.

  • Classes are presets. animate-spin, animate-pulse and the other classes call their mixin with the default options.
  • Mixins take a map. animate-spin((duration: 0.8s)) changes one option and keeps the defaults for the rest.
  • Keyframes once. Each mixin writes the animation declaration and an @keyframes rule. The keyframes name is built from the options, so the same options share one rule and different options get their own.
  • Plain times. Animation durations are written as given (1s, 0.5s). They are not the --nu-duration-* tokens, but a mixin accepts token(duration-700) as a duration.
Class Animation Duration Repeats
animate-pulse Opacity from 1 to 0.5 and back 1s Forever
animate-spin One full clockwise turn 1s, linear Forever
animate-ping Scales to 2 while fading out 1s Forever
animate-fade-in Opacity 0 to 1 0.5s Once, keeps the end state
animate-fade-out Opacity 1 to 0 0.5s Once, keeps the end state
animate-fade-in-up Fades in while rising 20px 0.5s Once, keeps the end state
animate-fade-in-down Fades in while dropping 20px 0.5s Once, keeps the end state
animate-shake Moves 5px left and right 0.5s Once
hover-ready Not an animation: a transition on all properties with the default duration and easing 200ms

Pulse, spin and ping repeat forever. Use them for states that last an unknown time, such as loading or a live status.

Loading
<div class="flex wrap x-center gap-8 p-4 bg-alternate rounded">
<div class="animate-pulse w-24 p-3 bg-brand-subtle rounded">Loading</div>
<div class="animate-spin w-8 h-8 bg-brand rounded"></div>
<div class="relative w-4 h-4">
<div class="animate-ping absolute inset-0 rounded-full bg-brand"></div>
<div class="relative w-4 h-4 rounded-full bg-brand"></div>
</div>
</div>

The fade classes run once and keep their last frame (animation-fill-mode: forwards), so an element that fades out stays hidden. It still takes up space and still receives clicks; hide or remove it once the animation ends.

animate-fade-in
animate-fade-in-up
animate-fade-in-down
<div class="flex wrap gap-4 p-4 bg-alternate rounded">
<div class="animate-fade-in p-3 bg-surface border rounded">animate-fade-in</div>
<div class="animate-fade-in-up p-3 bg-surface border rounded">animate-fade-in-up</div>
<div class="animate-fade-in-down p-3 bg-surface border rounded">animate-fade-in-down</div>
</div>

The examples run once when the page loads. Reload the page to see them again.

animate-shake moves the element left and right once. Use it to draw attention to a field that failed validation, together with a visible error message.

animate-shake
<div class="p-4 bg-alternate rounded">
<div class="animate-shake p-3 bg-surface border rounded">animate-shake</div>
</div>

Bounce has no class. animate-bounce() moves the element to an offset and back, forever. The default is 0.5rem down.

.scroll-hint {
@include animate-bounce((vertical: 10px, duration: 2s));
}

hover-ready is a transition helper: it sets a transition on all properties with the default duration and easing tokens, so any hover change on the element animates.

.hover-ready {
transition-property: all;
transition-duration: var(--nu-duration-200);
transition-timing-function: var(--nu-ease-in-out);
}

For new code, prefer a specific transition class such as transition-colors or transition-transform; see Transitions.

The eight animate-* classes have responsive variants with an @{breakpoint} suffix, applied from the breakpoint upwards: animate-spin@md. hover-ready has none. Animation classes have no state variants.

SuffixMedia query
@xs@media (min-width: 480px)
@sm@media (min-width: 640px)
@md@media (min-width: 768px)
@lg@media (min-width: 1024px)
@xl@media (min-width: 1280px)
@2xl@media (min-width: 1536px)

Call the animation mixins with @include. Each takes one optional map; keys you leave out keep their defaults.

@use "@nuvoui/core" as *;
.spinner {
@include animate-spin((duration: 0.8s));
}
.spinner {
animation: anim-spin-normal 0.8s linear infinite;
}
@keyframes anim-spin-normal {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
Mixin Options and defaults Keyframes name
animate-bounce vertical: 0.5rem, horizontal: 0, duration: 1s, timing: ease-in-out, iteration: infinite anim-bounce-0-0_5rem
animate-pulse min-opacity: 0.5, max-opacity: 1, duration: 1s, timing: ease-in-out, iteration: infinite anim-pulse-50-100
animate-spin direction: normal, duration: 1s, timing: linear, iteration: infinite anim-spin-normal
animate-ping scale: 2, duration: 1s, timing: cubic-bezier(0, 0, 0.2, 1), iteration: infinite anim-ping-20
animate-fade type: in, duration: 0.5s, timing: ease-in-out, iteration: 1 forwards anim-fade-in
animate-shake intensity: 5px, duration: 0.5s, timing: ease-in-out, iteration: 1 anim-shake-5px
  • animate-spin: any direction other than normal turns anticlockwise (direction: reverse gives anim-spin-reverse).
  • animate-fade: type is in, out, in-up or in-down.
  • animate-pulse: write the opacities as numbers between 0 and 1.
  • animate-bounce and animate-shake: write distances with a unit.
  • iteration is written after the easing in the animation shorthand, so it can also carry a fill mode or a direction, as the fade default 1 forwards does.
  • Unknown option keys are ignored.
.badge-new {
@include animate-ping((scale: 1.5, iteration: 3));
}
.nudge {
@include animate-bounce((vertical: 0, horizontal: 4px, duration: token(duration-700)));
}

ensure-keyframes($name) writes an @keyframes rule the first time a name is used and nothing on later calls, so a mixin that is included many times emits its keyframes once.

@mixin toast-in {
animation: toast-in 0.3s ease-out;
@include ensure-keyframes(toast-in) {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}
.toast {
@include toast-in;
}
.snackbar {
@include toast-in;
}

Spin, ping, bounce, fade in up, fade in down and shake animate the transform property directly. While one runs, it replaces the transform written by the transform utilities, so rotate-45 animate-spin spins from 0deg, not from 45deg. To combine them, put the animation on a wrapper element and the transform on its child.

Turn the classes off with the animations feature flag. The mixins stay available.

@use "@nuvoui/core" with (
$generate-utility-classes: true,
$feature-flags: ("animations": false)
);

hover-ready reads $transition-default-duration and $transition-default-ease; see Transitions.

Input Error
animate-fade((type: slide)) animate-fade(): unknown type slide. Use in, out, in-up or in-down.
// error
.panel {
@include animate-fade((type: slide));
}

Generated from the compiled core build. Responsive variants are not listed.

Animations (8 rules)
SelectorDeclarationsDefault value
.animate-pulseanimation: anim-pulse-50-100 1s ease-in-out infiniteanim-pulse-50-100 1s ease-in-out infinite
.animate-spinanimation: anim-spin-normal 1s linear infiniteanim-spin-normal 1s linear infinite
.animate-pinganimation: anim-ping-20 1s cubic-bezier(0, 0, 0.2, 1) infiniteanim-ping-20 1s cubic-bezier(0, 0, 0.2, 1) infinite
.animate-fade-inanimation: anim-fade-in 0.5s ease-in-out 1 forwardsanim-fade-in 0.5s ease-in-out 1 forwards
.animate-fade-outanimation: anim-fade-out 0.5s ease-in-out 1 forwardsanim-fade-out 0.5s ease-in-out 1 forwards
.animate-fade-in-upanimation: anim-fade-in-up 0.5s ease-in-out 1 forwardsanim-fade-in-up 0.5s ease-in-out 1 forwards
.animate-fade-in-downanimation: anim-fade-in-down 0.5s ease-in-out 1 forwardsanim-fade-in-down 0.5s ease-in-out 1 forwards
.animate-shakeanimation: anim-shake-5px 0.5s ease-in-out 1anim-shake-5px 0.5s ease-in-out 1
Hover ready (1 rules)
SelectorDeclarationsDefault value
.hover-readytransition-property: allall
  • Reduced motion. The reset contains this rule, which is emitted with core and has no feature flag:

    @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
    }
    }

    A user who asks for less motion sees each animation finish at once and run only once: a fade lands on its end state, and a spinner or a pulse stops. Because the reset is a cascade layer, its !important wins over utility classes and unlayered CSS.

  • Do not rely on motion alone. A stopped spinner must still read as loading. Pair it with text, such as a visually hidden “Loading” label or aria-busy="true" on the region that is updating.

  • Custom reduced motion styles. The reset applies to every animation, including your own, so a replacement animation under reduced motion is also cut short. Give a static alternative instead with the reduced-motion mixin, which wraps its content in @media (prefers-reduced-motion: reduce):

    .spinner {
    @include animate-spin;
    @include reduced-motion {
    animation: none;
    opacity: 0.6;
    }
    }
  • Flashing. Keep looping animations slow and low in contrast. Content must not flash more than three times per second (WCAG 2.3.1).

  • Pause what loops. A looping animation that starts on its own and lasts more than five seconds next to other content needs a way to pause, stop or hide it (WCAG 2.2.2).

  • RTL. Animations do not depend on direction. Bounce, shake and the fades move on the physical axes, and spin turns clockwise in both directions.