Skip to content

Transitions and animations

docs.scrimba.com

You add a :hover colour to a button and it works, but the change is abrupt: one frame it is blue, the next it is darker, with nothing in between. A tiny fade would make the whole page feel less jumpy. That fade is a transition, and once you can describe motion between two states, the same tools scale up to hover effects, cards that lift, and a spinner that turns while something loads. This chapter is about how CSS moves things, and how to move them without making the page harder to use.

Transitions

A transition tells the browser to animate a property from its old value to a new one instead of snapping between them. You describe two states, the normal one and the hover one, and the transition fills in the frames in between.

Here is a button that changes colour on hover. Without a transition the colour jumps; with one it fades:

css
.btn {
  background: #3b82f6;
  transition: background 0.2s;   /* fade the colour over 0.2 seconds */
}
.btn:hover {
  background: #1d4ed8;           /* the new state to animate towards */
}

You put transition on the normal state, not on :hover. That way it applies both when the mouse arrives and when it leaves.

A transition animates a property between two values whenever that value changes, usually because a state like :hover or :focus kicks in, or a class is added. You are not describing the animation frame by frame; you name the property and a duration, and the browser interpolates the values in between.

The transition shorthand has four parts, in this order: the property to watch, the duration, the timing-function (the speed curve), and an optional delay.

css
.btn {
  background: #3b82f6;
  transition: background 0.2s ease-in-out 0s;
  /* property | duration | timing-function | delay */
}
.btn:hover {
  background: #1d4ed8;
}

You can transition several properties at once by separating them with commas, and you declare the transition on the base state so it covers both entering and leaving the hover.

css
.card {
  transition: transform 0.2s, box-shadow 0.2s;   /* two properties, same timing */
}

A transition interpolates a property between its start and end value over time whenever the computed value changes. The trigger is any change to that property: a pseudo-class like :hover, a class toggled from JavaScript, or an inline style edit. The four sub-properties are transition-property, transition-duration, transition-timing-function, and transition-delay, and the shorthand orders them property, duration, timing, delay.

Two constraints decide whether a transition runs at all. The property has to be animatable, meaning it has a defined interpolation between two values: background-color, opacity, and transform are, while a property like display is discrete and historically snapped rather than tweening. And both the start and end values have to be concrete: you cannot transition to or from auto, which is why animating height to auto does nothing and people reach for max-height or transform instead.

css
.btn {
  background: #3b82f6;
  transition: background-color 0.2s ease-in-out;   /* animatable, both values concrete */
}
.btn:hover {
  background: #1d4ed8;
}

Declare the transition on the base rule, not the :hover rule. The transition property in force is read from the element's current state, so putting it on the base state means it governs both the arrival and the return; putting it only on :hover animates the way in and snaps the way out.

JunoTransitions A transition smooths a change instead of snapping it, like fading a button's colour on hover. You write it on the normal state, not on :hover, so it works going in and coming back out. Start with property and a duration and you already have a working fade.
JunoTransitions The transition shorthand reads property, duration, timing-function, delay, and you list several by separating them with commas. Put it on the base rule so it covers entering and leaving the state. If a hover looks smooth going in and jumps coming out, the transition is on :hover instead of the base, which catches people often.
JunoTransitions A transition only runs on animatable properties with two concrete end values, which is why transitioning to height: auto quietly does nothing. Keep the declaration on the base state so it governs both directions rather than snapping on the way out. Once you know the four sub-properties, the shorthand is only their order: property, duration, timing, delay.

Transforms

A transform moves, scales, or rotates an element without disturbing anything around it. It is the thing you usually want to animate, because it looks smooth and does not shove neighbours out of the way.

There are three you will reach for constantly:

css
.card:hover {
  transform: translateY(-4px);   /* move up 4 pixels */
}
.icon:hover {
  transform: scale(1.1);         /* grow to 110% of its size */
}
.arrow.open {
  transform: rotate(90deg);      /* turn a quarter turn clockwise */
}

translate moves, scale resizes, and rotate turns. Pair any of these with a transition and you get a card that lifts, an icon that pops, or an arrow that swings open.

transform applies a visual change to an element, moving, resizing, or rotating it, without changing its place in the layout. That last part is the point: a transformed element is painted in a new position, but the space it originally occupied stays reserved, so its neighbours do not reflow. This is why transform is the property you animate for movement instead of top or margin.

The three you use most are translate(), scale(), and rotate(), and you can combine them in one declaration:

css
.card {
  transition: transform 0.2s ease-out;
}
.card:hover {
  transform: translateY(-4px) scale(1.02);   /* lift and grow slightly */
}

The functions apply left to right, and transform-origin sets the point they pivot around, which defaults to the element's centre. Change it to top left and a rotate swings from the corner instead.

css
.badge {
  transform-origin: top left;
  transform: rotate(-8deg);
}

transform repositions an element in its own painted layer without affecting document flow: the box's original space stays reserved, so no sibling reflows and no layout is recomputed when the transform changes. That property is what makes transform the right thing to animate, a point the Deep Dive on performance below makes precise.

translate(), scale(), and rotate() are the common functions, and order is significant because they compose as matrix multiplication applied right to left in effect: translateX(50px) rotate(45deg) moves then rotates the moved element, while the reverse rotates first and then translates along the rotated axes, giving a different result. transform-origin sets the pivot for scale and rotate, defaulting to the element's centre (50% 50%).

css
.panel {
  transform-origin: top center;
  transition: transform 0.25s ease-out;
}
.panel.collapsed {
  transform: scaleY(0);          /* collapses towards the top edge */
}

One practical constraint: transform only applies to elements that generate a box you can position this way, so it has no effect on inline elements like a bare <span>. Give the element display: inline-block or block first. If you are also positioning the element, note how transforms interact with positioning contexts, since a transformed element becomes a containing block for its absolutely positioned descendants.

JunoTransformstransform moves, scales, or rotates an element without pushing its neighbours around. The three to remember are translate to move, scale to resize, and rotate to turn. Combine it with a transition and you get a card that lifts or an icon that pops on hover.
JunoTransformstransform changes how an element looks without changing where it sits in the layout, so neighbours never reflow, which is why you animate it instead of top or margin. Stack translate, scale, and rotate in one declaration and they apply left to right. Reach for transform-origin when you want a rotate or scale to pivot somewhere other than the centre.
JunoTransforms Transform functions compose in order, so translateX(50px) rotate(45deg) is not the same as the reverse, and transform-origin sets what a rotate or scale pivots around. It does nothing on a bare inline element until you make it inline-block. Keep in mind it also turns the element into a containing block for absolutely positioned children, which can surprise you later.

Keyframe animations

A transition animates between two states. When you need more than two, or you need the motion to repeat, you use a keyframe animation. You write out the steps with @keyframes, then attach them to an element with the animation property.

A loading spinner is the classic example: it turns forever, so a transition cannot do it.

css
@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
.spinner {
  animation: spin 1s linear infinite;   /* run spin, 1s per turn, forever */
}

from is the start and to is the end. The animation line says which keyframes to run, how long one cycle takes, the speed curve, and how many times to repeat. infinite means it never stops.

@keyframes defines a named sequence of steps, and the animation property runs it on an element. Unlike a transition, an animation does not need a state change to trigger it, it starts on its own, and it can have as many steps as you like, using percentages from 0% to 100%.

css
@keyframes pulse {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.05); }   /* the midpoint step */
  100% { transform: scale(1); }
}
.badge {
  animation: pulse 1.5s ease-in-out infinite;
}

The animation shorthand bundles several sub-properties: name, duration, timing-function, delay, iteration-count, and direction. infinite loops forever, a number runs that many times, and alternate as a direction makes each cycle reverse so the motion runs back and forth instead of jumping to the start.

css
.spinner {
  animation: spin 1s linear infinite;
}

@keyframes declares a named timeline of property values at points expressed as percentages (from and to are aliases for 0% and 100%), and animation binds that timeline to an element. An animation is self-triggering and can loop, which is the line between it and a transition: use a transition to smooth a state change, use an animation for motion that runs on its own or repeats.

The animation shorthand carries eight sub-properties: animation-name, -duration, -timing-function, -delay, -iteration-count, -direction, -fill-mode, and -play-state. Two of them catch people out. animation-fill-mode: forwards makes the element hold its final keyframe after the animation ends, rather than snapping back to its unanimated style, which is what you want for a one-shot entrance. And the timing-function applies per keyframe segment, not across the whole animation, so ease-in-out on a three-step animation eases in and out of each segment, not once end to end.

css
@keyframes slide-in {
  from { opacity: 0; transform: translateX(-16px); }
  to   { opacity: 1; transform: translateX(0); }
}
.toast {
  animation: slide-in 0.3s ease-out forwards;   /* holds the end state */
}

Prefer animating transform and opacity inside keyframes for the same reason transitions do, which the next section unpacks: those two properties are cheap to animate and most others are not.

JunoKeyframe animations When two states are not enough, or the motion needs to repeat, write the steps in @keyframes and run them with animation. A spinner is the go-to example: from at 0 degrees, to at 360, looping with infinite. The animation line sets which keyframes, how long, the curve, and how many times.
JunoKeyframe animations@keyframes holds the steps as percentages and animation runs them, no state change needed. The shorthand packs name, duration, timing, delay, iteration-count, and direction, so infinite loops and alternate makes each cycle run back and forth. Multi-step motion and anything that repeats belongs here, not in a transition.
JunoKeyframe animations The timing-function runs per keyframe segment, not once across the whole animation, which surprises people the first time an eased multi-step run looks lumpy. Reach for animation-fill-mode: forwards when a one-shot entrance should hold its end state instead of snapping back. And keep keyframes on transform and opacity where you can, for the performance reason coming up next.

Motion that respects the user

Not everyone wants motion on the screen. Some people find it distracting, and for some it can cause real discomfort or nausea. Browsers let people ask for less of it in their system settings, and CSS can read that request with prefers-reduced-motion.

Wrap your animations in a check so they only run for people who have not asked to turn motion down:

css
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.2s;
  }
}

The other half of respecting the user is restraint: keep motion short, subtle, and tied to something the reader did, like a hover or a click. Motion that draws attention to a real change helps; motion for its own sake gets tiring fast.

Motion is not neutral. For some readers, animation is a distraction, and for people with vestibular conditions, large or sudden motion can trigger dizziness or nausea. Operating systems expose a "reduce motion" setting, and the prefers-reduced-motion media feature lets your CSS honour it.

Treat motion as opt-in, not opt-out. Write the calm baseline as your default, then layer motion inside a no-preference query:

css
.card {
  transition: none;              /* calm default for everyone */
}
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.2s ease-out;
  }
}

Beyond the media query, purpose is what separates good motion from noise. Motion earns its place when it explains a change: a menu that slides from the edge it is anchored to, a toast that fades so it does not appear from nowhere. Fast and subtle reads as polish; slow and large reads as an obstacle.

prefers-reduced-motion is a media feature reflecting an accessibility setting the reader chose at the OS level, and honouring it is a baseline requirement, not a nicety, because unbidden motion can cause vestibular discomfort. The pattern to reach for is to author the reduced state as the default and enable motion inside @media (prefers-reduced-motion: no-preference), so a reader whose browser does not support the feature still gets the calm baseline rather than the motion.

"Reduce" rarely means "remove". A fade or a colour change is usually fine when a slide or a scale is not, so the useful move is to swap large positional motion for a small opacity change rather than kill all feedback:

css
.modal {
  animation: slide-in 0.3s ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .modal {
    animation: fade-in 0.3s ease-out;   /* keep feedback, drop the movement */
  }
}

Purpose and easing carry the rest. Entrances read best with ease-out, which starts fast and settles, because the element arrives with intent and comes to rest; exits often suit ease-in. Keep durations short (roughly 150ms to 300ms for interface feedback) and motion small; a common failure is a lovely animation that adds a quarter-second of waiting to every interaction. Motion should clarify a change of state, not decorate one.

JunoMotion that respects the user Some people find motion distracting or even physically uncomfortable, and they can ask the browser for less of it. Read that with prefers-reduced-motion and only run animations for people who have not turned motion down. Keep the motion you do add short, subtle, and tied to something the reader did.
JunoMotion that respects the user Author the calm version as your default and add motion inside a no-preference query, so motion is opt-in rather than something people have to fight off. Good motion explains a change, like a menu sliding from its anchor; motion for decoration wears thin fast. Fast and small reads as polish, slow and large reads as an obstacle.
JunoMotion that respects the user Reduce rarely means remove: swap a slide for a fade so people who asked for less motion still get feedback, and default to the calm state so unsupported browsers land there too. Use ease-out for entrances and keep interface feedback around 150 to 300 milliseconds. A lovely animation that adds a wait to every click is a cost, not a feature.

What to animate, and why it matters

A quick rule that will save you trouble later: when you animate movement, animate transform and opacity, not properties like width, height, top, or margin.

css
/* smooth: the browser handles these cheaply */
.card:hover {
  transform: translateY(-4px);
  opacity: 0.9;
}

The reason is that transform and opacity are the two properties browsers can move around most cheaply, so animations built on them stay smooth even on slower devices. Animating size or position properties makes the browser do more work every frame, and the motion can stutter. You will meet the full reason when you go deeper, but the habit is worth building now.

There is a reason the examples in this chapter lean on transform and opacity. Those two properties are the cheapest things to animate, and most others are not. Animating width, height, top, or margin forces the browser to recalculate layout on every frame, which is expensive and shows up as stutter on slower devices.

The practical rule is to express motion as transform and opacity wherever you can. Want to move something? Use translate, not top. Want to resize it? Use scale, not width. Want it to appear? Fade opacity, do not animate height from zero.

css
/* costly: animating a layout property */
.panel {
  transition: height 0.3s;       /* recalculates layout every frame */
}

/* cheap: same effect, expressed as transform */
.panel {
  transition: transform 0.3s;
  transform: scaleY(0);          /* no layout work */
}

This connects back to why transform does not disturb its neighbours: because it never touches layout, the browser can animate it without redoing the page. If you find yourself reaching for a colour or background transition too, colours and backgrounds covers which of those interpolate.

This is the Deep Dive the earlier sections pointed at: why transform and opacity are cheap to animate and almost everything else is not. It comes down to the browser's rendering pipeline, which runs in three stages. Layout computes the size and position of every box. Paint fills in pixels: colours, text, borders. Composite assembles the painted layers into the final frame, and it is the stage the GPU can drive.

Animating transform or opacity can be done entirely in the composite stage. The browser paints the element once, promotes it to its own compositor layer (a separately painted surface the GPU can move and blend independently), and then each frame only re-composites that layer at a new position or opacity. No layout, no repaint. Animating width or top instead invalidates layout, forcing the browser to recompute geometry and repaint on every single frame, which is what drops frames under load.

css
/* composite-only: cheap, GPU-friendly */
.card {
  transition: transform 0.2s, opacity 0.2s;
}

/* triggers layout and paint each frame: avoid animating these */
.card-heavy {
  transition: width 0.2s, top 0.2s;
}

will-change lets you hint that a property is about to animate so the browser can promote the layer ahead of time: will-change: transform. Use it sparingly and remove it when the animation is done, because each promoted layer costs memory, and promoting everything defeats the purpose and can slow the page down. Treat it as a targeted fix for a measured stutter, not a default.

Easing is the other lever worth naming precisely. The timing-function is a curve mapping elapsed time to progress. ease-out (fast then slowing) suits entrances because the element arrives and settles; ease-in suits exits; linear suits continuous motion like a spinner. For anything custom, cubic-bezier(x1, y1, x2, y2) defines the curve by its two control points, and the built-in keywords are named cubic-beziers underneath. Keep a performance budget in mind: aim to stay within a frame's time (about 16ms at 60fps), animate composite-only properties, and reach for the profiler before you reach for will-change. If you drive several of these from custom properties, you can keep durations and easing consistent across a whole interface.

JunoWhat to animate, and why it matters Animate transform and opacity, and steer away from animating width, height, top, or margin. Those two are the cheapest for the browser to move, so the motion stays smooth even on slower devices. You will learn the full why later, but the habit is worth starting now.
JunoWhat to animate, and why it matters Express motion as transform and opacity: use translate instead of top, scale instead of width, and fade opacity instead of animating height from zero. Animating layout properties makes the browser redo layout every frame, which stutters on slower devices. It is the same reason transform leaves neighbours alone: it never touches layout.
JunoWhat to animate, and why it matterstransform and opacity can animate in the composite stage on their own GPU layer, so they skip layout and paint; width and top invalidate layout every frame and drop frames under load. Use will-change as a targeted fix for a measured stutter and remove it after, since each promoted layer costs memory. Pick easing to match the gesture, ease-out for entrances, and profile before you optimise.