CSS Animation | Transition, Animation, and Transform

Key takeaways

CSS animations tutorial: transitions, transforms, and @keyframes—timing functions, GPU-friendly motion, will-change, reduced motion, and production patterns.

Introduction

Motion brings interfaces to life

CSS animation adds life and interactivity to the web. You can build smooth motion without JavaScript.

Why animation matters:

  • UX: natural state changes
  • Feedback: responses to hover, tap, and focus
  • Attention: highlight important UI
  • Brand: distinctive motion design
  • Performance: GPU-accelerated properties can hit 60fps Three pillars:
  1. Transition: interpolate between state A and B
  2. Transform: move, rotate, and scale
  3. Animation: complex timelines with @keyframes

Transitions

Basic usage

A transition makes property changes smooth.

.box {
    width: 100px;
    height: 100px;
    background: #3498db;
    
    /* property | duration | timing | delay */
    transition: all 0.3s ease 0s;
}
.box:hover {
    width: 200px;
    background: #2ecc71;
}

How it works:

State A (initial)     State B (hover)
width: 100px    →     width: 200px
background: blue →    background: green
The transition interpolates over 0.3s

The transition is declared on the base .box rule, not on :hover, and that placement matters. When the transition lives on the base state, it applies in both directions: growing on hover and shrinking back when the pointer leaves. If you put it only inside :hover, the element animates on the way in and snaps back instantly on the way out, because once hover ends the transition declaration no longer applies. Only properties with interpolable values can transition: lengths, colors, numbers, transforms. display, background-image gradients and height: auto are the classic ones that do not (see the FAQ for display).

Multiple properties

.box {
    transition: width 0.3s ease,
                background-color 0.5s linear,
                transform 0.2s ease-out;
}
/* Or longhands */
.box {
    transition-property: width, background-color;
    transition-duration: 0.3s, 0.5s;
    transition-timing-function: ease, linear;
    transition-delay: 0s, 0.1s;
}

In the longhand form the lists pair up by position: width gets 0.3s, ease and no delay, background-color gets 0.5s, linear and a 0.1s delay. If one list is shorter, it repeats. A common bug is writing transition: width 0.3s; transition: opacity 0.3s; in two rules: the second declaration replaces the first entirely instead of adding to it, so the width no longer animates.

Timing functions

.box {
    transition-timing-function: linear;
    transition-timing-function: ease;
    transition-timing-function: ease-in;
    transition-timing-function: ease-out;
    transition-timing-function: ease-in-out;
    
    transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
    
    transition-timing-function: steps(4, end);
}

A timing function maps elapsed time to progress. linear moves at constant speed, which looks mechanical for UI but is right for spinners and progress bars. ease (the default) starts quickly and slows toward the end; ease-in starts slowly and accelerates, which suits elements leaving the screen; ease-out starts fast and decelerates, which suits elements arriving; ease-in-out is slow at both ends. cubic-bezier() defines a custom curve, and y values outside 0 to 1, as in the example, make the motion overshoot and settle back, a small “bounce”. steps(4, end) jumps in four discrete steps instead of interpolating, which is how sprite-sheet animations and typewriter effects are built. DevTools lets you edit these curves visually by clicking the curve icon next to the value.

Live demo

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .demo-container {
            display: flex;
            gap: 20px;
            padding: 20px;
            flex-wrap: wrap;
        }
        
        .box {
            width: 100px;
            height: 100px;
            background: #3498db;
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 10px;
            cursor: pointer;
        }
        
        .box-1 {
            transition: background 0.3s ease;
        }
        .box-1:hover {
            background: #e74c3c;
        }
        
        .box-2 {
            transition: transform 0.3s ease;
        }
        .box-2:hover {
            transform: scale(1.2);
        }
        
        .box-3 {
            transition: all 0.3s ease;
        }
        .box-3:hover {
            transform: rotate(45deg);
            background: #2ecc71;
        }
        
        .box-4 {
            transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
        }
        .box-4:hover {
            transform: scale(1.3) rotate(10deg);
        }
    </style>
</head>
<body>
    <div class="demo-container">
        <div class="box box-1">Color</div>
        <div class="box box-2">Scale</div>
        <div class="box box-3">Rotate</div>
        <div class="box box-4">Bounce</div>
    </div>
</body>
</html>

Transform

2D transforms

.box {
    transform: translate(50px, 100px);
    transform: translateX(50px);
    transform: translateY(100px);
    
    transform: scale(1.5);
    transform: scale(2, 0.5);
    transform: scaleX(2);
    transform: scaleY(0.5);
    
    transform: rotate(45deg);
    transform: rotate(-90deg);
    
    transform: skew(10deg, 20deg);
    transform: skewX(10deg);
    transform: skewY(20deg);
    
    /* Order matters when combining */
    transform: translate(50px, 50px) rotate(45deg) scale(1.5);
}

Order matters:

.box-1 {
    transform: rotate(45deg) translate(100px, 0);
}
.box-2 {
    transform: translate(100px, 0) rotate(45deg);
}

.box-1 rotates its coordinate system first, so the following translate(100px, 0) moves it 100px along the rotated x-axis, diagonally down and to the right. .box-2 moves 100px to the right and then rotates in place. Transforms also never affect layout: the element keeps its original space in the flow, and neighbors do not move out of the way. That is exactly why transforms are cheap to animate, and also why a scaled-up element overlaps its neighbors instead of pushing them aside.

Note that each transform declaration in the list above replaces the previous one; they are shown together only as a reference. To combine functions, put them in one declaration. Modern browsers also support the individual translate, rotate and scale properties, which can be set and transitioned independently without overwriting each other.

3D transforms

.box {
    transform: translateZ(100px);
    transform: translate3d(50px, 50px, 100px);
    
    transform: rotateX(45deg);
    transform: rotateY(45deg);
    transform: rotateZ(45deg);
    transform: rotate3d(1, 1, 1, 45deg);
    
    transform: scaleZ(2);
    transform: scale3d(1.5, 1.5, 2);
}

Perspective

.container {
    perspective: 1000px;
    perspective-origin: 50% 50%;
}
.box {
    transform: perspective(1000px) rotateY(45deg);
}

Example markup (structure used throughout this tutorial):

<style>
    .perspective-demo {
        display: flex;
        gap: 50px;
        padding: 50px;
    }
    
    .container {
        perspective: 1000px;
        width: 200px;
        height: 200px;
    }
    
    .box {
        width: 100%;
        height: 100%;
        background: #3498db;
        color: white;
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 2rem;
        transition: transform 0.6s;
    }
    
    .container:hover .box {
        transform: rotateY(180deg);
    }
    
    .container-1 { perspective: 500px; }
    .container-2 { perspective: 1000px; }
    .container-3 { perspective: 2000px; }
</style>
<div class="perspective-demo">
    <div class="container container-1">
        <div class="box">500px</div>
    </div>
    <div class="container container-2">
        <div class="box">1000px</div>
    </div>
    <div class="container container-3">
        <div class="box">2000px</div>
    </div>
</div>

Transform origin

.box {
    transform-origin: center center;
    transform-origin: top left;
    transform-origin: bottom right;
    transform-origin: 50% 50%;
    transform-origin: 0 0;
}

perspective is the distance between the viewer and the z = 0 plane. A small value such as 500px exaggerates depth, like holding the object close to your face; 2000px flattens it. The perspective property on a parent gives all its children one shared vanishing point, while the perspective() function inside transform gives each element its own, which looks different when several elements rotate side by side. transform-origin sets the pivot for rotation and scaling; the default is the element’s center, and rotate on a door-like panel needs transform-origin: left center to swing from its hinge.


Animation (@keyframes)

Defining keyframes

@keyframes slide {
    from {
        transform: translateX(0);
        opacity: 0;
    }
    to {
        transform: translateX(300px);
        opacity: 1;
    }
}
@keyframes bounce {
    0% {
        transform: translateY(0);
    }
    50% {
        transform: translateY(-100px);
    }
    100% {
        transform: translateY(0);
    }
}

Applying animations

.box {
    animation-name: slide;
    animation-duration: 2s;
    animation-timing-function: ease-in-out;
    animation-delay: 0s;
    animation-iteration-count: infinite;
    animation-direction: alternate;
    animation-fill-mode: forwards;
    animation-play-state: running;
    
    animation: slide 2s ease-in-out 0s infinite alternate forwards;
}

In the shorthand, the first time value is the duration and the second is the delay, so the order of 2s and 0s matters; everything else can appear in any order. alternate plays every second iteration backwards, which gives smooth back-and-forth motion without writing the return trip as extra keyframes. The individual properties are listed first only to show what the shorthand sets; in real CSS use one form or the other, because a later shorthand resets every longhand it does not mention.

Animation properties in depth

.box {
    animation-iteration-count: 1;
    animation-iteration-count: 3;
    animation-iteration-count: infinite;
    
    animation-direction: normal;
    animation-direction: reverse;
    animation-direction: alternate;
    animation-direction: alternate-reverse;
    
    animation-fill-mode: none;
    animation-fill-mode: forwards;
    animation-fill-mode: backwards;
    animation-fill-mode: both;
    
    animation-play-state: running;
    animation-play-state: paused;
}

Notification badge example

<style>
    @keyframes pulse {
        0%, 100% {
            transform: scale(1);
            opacity: 1;
        }
        50% {
            transform: scale(1.1);
            opacity: 0.8;
        }
    }
    
    .notification {
        position: relative;
        display: inline-block;
        padding: 10px 20px;
        background: #3498db;
        color: white;
        border-radius: 5px;
    }
    
    .notification::after {
        content: '';
        position: absolute;
        top: -5px;
        right: -5px;
        width: 20px;
        height: 20px;
        background: #e74c3c;
        border-radius: 50%;
        animation: pulse 1.5s ease-in-out infinite;
    }
</style>
<div class="notification">
    New message
</div>

The badge is a ::after pseudo-element, which only renders when it has a content value, even an empty string; leave out content: '' and nothing appears. 0%, 100% in one selector makes the start and end identical, so the infinite loop has no visible jump between iterations. Because only transform and opacity change, this pulse runs on the compositor and costs almost nothing, but an endlessly pulsing element still competes for attention, so reserve it for things that genuinely need it, and stop it under reduced-motion preferences (section 7).


More examples

Example 1: Button effects

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .button-demo {
            display: flex;
            gap: 20px;
            padding: 50px;
            flex-wrap: wrap;
        }
        
        .btn {
            padding: 15px 30px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            font-size: 1rem;
            cursor: pointer;
            text-decoration: none;
            display: inline-block;
        }
        
        .btn-lift {
            transition: all 0.3s ease;
        }
        .btn-lift:hover {
            transform: translateY(-5px);
            box-shadow: 0 10px 20px rgba(0,0,0,0.2);
        }
        .btn-lift:active {
            transform: translateY(-2px);
            box-shadow: 0 5px 10px rgba(0,0,0,0.2);
        }
        
        .btn-scale {
            transition: transform 0.3s ease;
        }
        .btn-scale:hover {
            transform: scale(1.1);
        }
        
        .btn-gradient {
            background: linear-gradient(90deg, #3498db, #2ecc71);
            background-size: 200% 100%;
            background-position: 0% 0%;
            transition: background-position 0.5s ease;
        }
        .btn-gradient:hover {
            background-position: 100% 0%;
        }
        
        .btn-border {
            position: relative;
            overflow: hidden;
            transition: color 0.3s ease;
        }
        .btn-border::before {
            content: '';
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: #2ecc71;
            transition: left 0.3s ease;
            z-index: -1;
        }
        .btn-border:hover {
            color: white;
        }
        .btn-border:hover::before {
            left: 0;
        }
        
        @keyframes shake {
            0%, 100% { transform: translateX(0); }
            25% { transform: translateX(-10px); }
            75% { transform: translateX(10px); }
        }
        .btn-shake:hover {
            animation: shake 0.3s ease;
        }
    </style>
</head>
<body>
    <div class="button-demo">
        <button class="btn btn-lift">Lift</button>
        <button class="btn btn-scale">Scale</button>
        <button class="btn btn-gradient">Gradient</button>
        <button class="btn btn-border">Border fill</button>
        <button class="btn btn-shake">Shake</button>
    </div>
</body>
</html>

A few details in these buttons are worth copying. .btn-lift sets a smaller lift on :active, so the button visibly “presses down” when clicked. .btn-gradient animates background-position over a background twice as wide as the button, because gradients themselves (background-image) cannot be transitioned. .btn-border slides a pseudo-element in from the left; overflow: hidden clips it while it is outside, and z-index: -1 puts it behind the text. Without a stacking context on the button that -1 can drop the pseudo-element behind the button’s own background, which is why adding z-index: 0 (or isolation: isolate) to .btn-border is a common fix when the fill seems not to appear. .btn-shake uses an animation rather than a transition because it has to go left, right and back, which is more than two states.

Example 2: Loading spinner

@keyframes spin {
    to { transform: rotate(360deg); }
}
.spinner {
    width: 40px;
    height: 40px;
    border: 4px solid #e0e0e0;
    border-top-color: #3498db;   /* one colored side makes rotation visible */
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

A keyframes block with only to animates from the element’s current value, here rotate(0). linear matters for continuous rotation: with the default ease, the spinner slows down and speeds up once per turn, which looks like stuttering. Because only transform changes, the spinner keeps turning smoothly even while the main thread is busy parsing or running JavaScript, which is exactly when loading indicators are on screen.

Example 3: Card flip

<style>
    .card { perspective: 1000px; width: 200px; height: 260px; }
    .card-inner {
        position: relative;
        width: 100%;
        height: 100%;
        transition: transform 0.6s;
        transform-style: preserve-3d;      /* keep children in 3D space */
    }
    .card:hover .card-inner { transform: rotateY(180deg); }
    .card-face {
        position: absolute;
        inset: 0;
        backface-visibility: hidden;       /* hide the side facing away */
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 10px;
    }
    .card-front { background: #3498db; color: white; }
    .card-back  { background: #2ecc71; color: white; transform: rotateY(180deg); }
</style>
<div class="card">
    <div class="card-inner">
        <div class="card-face card-front">Front</div>
        <div class="card-face card-back">Back</div>
    </div>
</div>

Three properties do the work. perspective on the outer card makes the rotation look three-dimensional. transform-style: preserve-3d on the rotating element lets its children keep their own 3D positions instead of being flattened onto it. backface-visibility: hidden hides whichever face is turned away, and the back face starts pre-rotated by 180° so it faces the viewer once the card turns. If the flip shows a mirrored front face, one of those three is missing. Note that overflow: hidden on .card-inner forces flattening and breaks the effect, which is a common surprise when a border-radius clip is added later.

Example 4: Staggered fade-in

@keyframes fade-up {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: translateY(0); }
}
.list-item {
    animation: fade-up 0.4s ease-out both;
    animation-delay: calc(var(--i) * 80ms);
}
<li class="list-item" style="--i: 0">First block</li>
<li class="list-item" style="--i: 1">Second block</li>
<li class="list-item" style="--i: 2">Third block</li>

both as the fill mode is the important part. During the delay, backwards applies the from keyframe, so items wait invisibly instead of appearing, disappearing and fading back in; after the animation, forwards keeps them at the to state. A custom property for the index keeps the CSS to one rule instead of one class per position. Keep the total stagger short (a few hundred milliseconds for the whole list): a long cascade looks nice once and then makes every page load feel slow.


Performance

Every frame the browser may have to recalculate styles, then layout (compute sizes and positions), then paint (fill in pixels), then composite (assemble painted layers on the GPU). Changing width or top invalidates layout, which can ripple to every element whose position depends on it, followed by repaint. Changing transform or opacity on an element that has its own compositor layer skips layout and paint entirely: the GPU just moves or fades an already-painted texture. At 60 frames per second the browser has about 16 ms per frame for everything, so the difference between the two is often the difference between smooth and janky motion on a mid-range phone.

GPU-friendly properties

Fast (compositor-friendly):

.box {
    /* ✅ Preferred: no layout thrash */
    transform: translateX(100px);
    transform: scale(1.5);
    transform: rotate(45deg);
    opacity: 0.5;
}

Slower (layout/paint):

.box {
    width: 200px;
    height: 200px;
    left: 100px;
    top: 100px;
    margin: 20px;
    padding: 20px;
}

Comparison

PropertyLayoutPaintCompositeCost
transformnonoyeslow
opacitynonoyeslow
background-colornoyesyesmedium
width, heightyesyesyeshigh
left, topyesyesyeshigh

The practical translation: to move something, use translate instead of left/top; to grow it, use scale instead of width/height where the visual result is acceptable (scaling also scales text and borders); to show or hide it, animate opacity. box-shadow and filter: blur() are paint-heavy; a common trick for an animated shadow is to put the shadow on a pseudo-element and animate that element’s opacity instead.

will-change

.box {
    will-change: transform, opacity;
}

Use sparingly—only on elements that will actually animate. Prefer toggling will-change from JavaScript around hover/animation lifecycle.

will-change asks the browser to promote the element to its own layer ahead of time, so the first frame of the animation does not stall while the layer is created. Each layer costs GPU memory, and applying will-change to hundreds of elements, or to *, can make a page slower and, on mobile, can exhaust GPU memory. It also creates a stacking context and, for transform, a containing block for position: fixed children, which can change layout in surprising ways.

Forcing GPU layers

.box {
    transform: translateZ(0);
}

This is the older hack for the same promotion, from before will-change was widely supported. It has the same costs and the same side effects, and is best left to cases where you have measured a problem in the Performance panel.


Common issues

  1. Janky motion: animate transform/opacity instead of width/height/position when possible. The Performance panel in Chrome DevTools shows long purple “Layout” and green “Paint” blocks during the animation when this is the cause, and the Rendering tab’s “Paint flashing” highlights what repaints every frame.
  2. Animation on load: gate with a class or animation-delay so content does not surprise users. A related problem is transitions firing on page load because styles arrive in two steps; adding a preload class to <body> that disables transitions and removing it after load is a common fix.
  3. Snap back after animation: use animation-fill-mode: forwards. Without it, the element returns to its non-animated styles the moment the animation ends. (With infinite iterations, fill mode after the end never applies, so forwards in the earlier shorthand example has no effect.)
  4. Unexpected transform results: each function in a transform list operates in the coordinate system produced by the functions to its left. rotate(45deg) translate(100px, 0) first rotates the axes, so the element moves 100px diagonally; translate(100px, 0) rotate(45deg) moves right and then spins in place. The two .box rules in the “Order matters” section show exactly this difference.
  5. z-index ignored: give the element position and a stacking context when combining with transform. Any transform other than none creates a new stacking context, so children’s z-index values only compete with each other inside it, and a dropdown inside a transformed card can end up behind the next card no matter how large its z-index is.
  6. transition: all side effects: it transitions every property that changes, including ones you did not intend, such as a color change from a theme switch or a layout property changed by a media query. Listing the properties explicitly (transition: transform 0.3s, opacity 0.3s) is more predictable and avoids animating expensive properties by accident.

The first time an animation of mine stuttered on a real phone, the cause was not the keyframes at all: a box-shadow transition on hover over a long list of cards forced a repaint of every card each frame. It looked perfect on a desktop GPU. Testing motion on the slowest device you target, or with CPU throttling in DevTools, finds this kind of problem early.


Accessibility: prefers-reduced-motion

Some users get dizzy or nauseous from large motion, and operating systems let them ask for less of it. CSS can read that setting:

@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;
    }
}

The blanket rule above is a common safety net, using a near-zero duration rather than none so that animationend and transitionend events still fire for scripts that wait on them. A more thoughtful approach keeps small, functional motion (a color fade, a focus ring) and removes large movement such as parallax, zooms and sliding panels. Timing guidance: roughly 150 to 300 ms suits most UI transitions, and ease-out feels natural for elements entering the screen because they decelerate into place.


Animating from JavaScript

  • Stack multiple animations on one element: animation: spin 1s linear infinite, pulse 2s ease-in-out infinite; works as long as they animate different properties. Two animations on the same transform override each other; combine them in one keyframes block or use separate wrapper elements.
  • Trigger CSS animations by toggling classes, and listen for animationend or transitionend to clean up. Restarting the same animation requires removing the class, forcing a reflow (reading element.offsetWidth), and adding it again.
  • Pause and resume with animation-play-state: paused.
  • For sequencing, scrubbing or animating values CSS cannot express, the Web Animations API (element.animate(keyframes, options)) gives you the same compositor-friendly engine with promises and playback control.

Browser support

FeatureChromeFirefoxSafariEdgeIE
transition26+16+9+12+10+
transform36+16+9+12+10+
animation43+16+9+12+10+
will-change36+36+9.1+79+—

Every current browser supports these unprefixed. Vendor prefixes (-webkit- and friends) only matter for very old browsers; if you must support them, let Autoprefixer add them from a Browserslist config instead of writing them by hand. The newer features mentioned in this article, such as @starting-style and transition-behavior, have much more recent support and are worth checking on caniuse.com before relying on them.



Frequently Asked Questions (FAQ)

Q. Why doesn’t my fade-in transition work when I switch an element from display: none to display: block?

A. display is a discrete property, so a normal transition cannot interpolate it: the element appears instantly, and because it had no rendered box in the previous frame, the opacity change has no starting value to animate from either. The long-standing workaround is to keep the element rendered and transition opacity and transform together with visibility, which switches at the start or end of the transition. Newer browsers also support transition-behavior: allow-discrete combined with @starting-style for real enter and exit transitions on display, so check support for your target browsers before relying on it.