Animations & Transitions

transition vs @keyframes animation, easing functions, and a complete animated toast notification component.

transition: animating between two states

A transition smoothly interpolates a property from its old value to its new value whenever that value changes — triggered by a :hover, a class toggle, or a JavaScript-driven style change. It has no idea of any state in between; it only needs a start and an end:

Css
.button {
    background: #3b82f6;
    transition: background 0.2s ease;
}

.button:hover {
    background: #2563eb;
}

The transition shorthand packs four pieces of information: property duration timing-function delay.

Css
.card {
    transition: transform 0.3s ease-out, box-shadow 0.3s ease-out;
    /*          ^property   ^duration ^easing         (delay omitted, defaults to 0s) */
}

.card:hover {
    transform: translateY(-4px);
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
}

Use all to transition every animatable property that changes, rather than listing each one — convenient, but it also transitions properties you didn't intend to animate if something else on the element changes unexpectedly:

Css
.button {
    transition: all 0.2s ease;
}

Easing functions

The timing function shapes how a transition or animation moves between its two values over time — not just linearly, but accelerating, decelerating, or both:

Function Feel
linear Constant speed throughout — often feels mechanical/unnatural for UI motion.
ease (default) Starts slow, speeds up, ends slow — the most commonly used general-purpose default.
ease-in Starts slow, accelerates, ends abruptly — good for something leaving the screen.
ease-out Starts fast, decelerates smoothly — good for something entering the screen or settling into place.
ease-in-out Slow at both ends, fast in the middle.
cubic-bezier(x1, y1, x2, y2) A fully custom curve — lets you define a bespoke easing feel beyond the five keyword presets.
Css
.toast {
    transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); /* a slight overshoot "bounce" */
}

@keyframes and the animation property

A keyframe animation defines any number of intermediate steps, not just a start and end, and can run automatically, repeat, and reverse — none of which a transition can do on its own.

Css
@keyframes spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}

.spinner {
    width: 20px;
    height: 20px;
    border: 3px solid #e5e7eb;
    border-top-color: #3b82f6;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

@keyframes can also use percentages for more than two steps:

Css
@keyframes pulse {
    0%   { transform: scale(1);    opacity: 1; }
    50%  { transform: scale(1.05); opacity: 0.8; }
    100% { transform: scale(1);    opacity: 1; }
}

.notification-dot {
    animation: pulse 1.5s ease-in-out infinite;
}

The animation shorthand packs several sub-properties, most usefully: animation-name, animation-duration, animation-timing-function, animation-iteration-count (a number, or infinite), animation-direction (normal, reverse, alternate), and animation-fill-mode (whether the element keeps the first/last keyframe's styles before it starts or after it ends).

Css
.slide-in {
    animation: slideIn 0.4s ease-out forwards; /* "forwards" keeps the final keyframe's state after it finishes */
}

@keyframes slideIn {
    from { transform: translateX(-100%); opacity: 0; }
    to   { transform: translateX(0);     opacity: 1; }
}

transition vs. animation

transition animation (@keyframes)
Needs a trigger Yes — a state change (:hover, a class toggle, a JS style change) No — can run automatically on load
Number of steps Two (from → to) Any number, via percentage keyframes
Looping No Yes (animation-iteration-count: infinite)
Reversing mid-flight Naturally, by interpolating back if the state reverts Only with explicit animation-direction control
Typical use Hover/focus feedback, simple state changes Loaders, attention-grabbers, multi-step entrances

A complete example: an animated toast notification

Combining a transition-driven slide-in/out toast with a @keyframes-driven loading spinner shown inside it:

HTML
<button id="show-toast">Save changes</button>

<div id="toast" class="toast" role="status" aria-live="polite">
    <span class="spinner" hidden></span>
    <span class="toast-message"></span>
</div>
Css
.toast {
    position: fixed;
    bottom: 1.5rem;
    right: 1.5rem;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    background: #111827;
    color: white;
    padding: 0.75rem 1.25rem;
    border-radius: 8px;
    opacity: 0;
    transform: translateY(1rem);
    transition: opacity 0.3s ease, transform 0.3s ease;
    pointer-events: none;
}

.toast.visible {
    opacity: 1;
    transform: translateY(0);
}

.spinner {
    width: 14px;
    height: 14px;
    border: 2px solid rgba(255, 255, 255, 0.3);
    border-top-color: white;
    border-radius: 50%;
    animation: spin 0.7s linear infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}
Javascript
const button = document.getElementById('show-toast');
const toast = document.getElementById('toast');
const spinner = toast.querySelector('.spinner');
const message = toast.querySelector('.toast-message');
let hideTimeout;

button.addEventListener('click', () => {
    spinner.hidden = false;
    message.textContent = 'Saving...';
    toast.classList.add('visible');

    // Simulate a save completing
    setTimeout(() => {
        spinner.hidden = true;
        message.textContent = 'Saved successfully!';
    }, 900);

    clearTimeout(hideTimeout);
    hideTimeout = setTimeout(() => {
        toast.classList.remove('visible');
    }, 3000);
});

The toast's entrance/exit uses transition because it only ever needs two states (hidden vs. visible), each fully described by a single opacity/transform pair. The spinner inside it uses @keyframes because it needs to loop continuously with no natural "end state" to transition to — exactly the case each tool is actually suited for.

Common mistakes

  • Trying to transition a property change that never actually happens on a single element (like display: nonedisplay: block) — display isn't animatable at all, and a component that's removed from layout can't meaningfully transition its other properties either. Fade with opacity/visibility instead, or restructure the toggle to avoid display entirely.
  • Using transition: all broadly and then being surprised when an unrelated property change (a layout shift, a different state class) animates when it shouldn't have.
  • Forgetting animation-fill-mode: forwards on an entrance animation, causing the element to snap back to its first keyframe's state the instant the animation completes, undoing the visual effect it just played.
  • Animating width/height/top/left for movement instead of transform: translate()/scale() — transform-based animation can run on the compositor thread and stays smooth even under load, while animating layout properties forces the browser to recalculate layout on every frame.