RizTech Academy logo
RizTech Academy
Modern CSS and WorkflowLesson 4 of 525 min

Transitions and tasteful animation

Animation on the web is mostly used badly. This lesson is the small amount that improves a page and the rather larger amount that makes it worse, plus the one performance rule that decides whether it feels smooth on a mid-range phone.

Transitions

.button {
  background: var(--colour-accent);
  transition: background-color 150ms ease, transform 150ms ease;
}

.button:hover {
  background: var(--colour-accent-hover);
}

.button:active {
  transform: translateY(1px);
}

The transition goes on the base state, not the hover. Put it on :hover and it animates in and snaps back, because the transition no longer exists once the mouse leaves. That is the most common transition mistake and it is entirely invisible in code review unless you know to look.

The shorthand

transition: <property> <duration> <timing-function> <delay>;
transition: opacity 200ms ease-out 50ms;
transition: opacity 200ms, transform 200ms;      /* two properties */

Name the properties. transition: all 200ms looks convenient and is a bad habit: it animates properties you did not intend, including ones a browser adds later, and it makes the browser check every property on every change.

The unit is not optional — transition: opacity 0 is invalid, as the units lesson said.

Durations that feel right

75–150ms    a small state change: a hover, a colour, a focus ring
200–300ms   something moving or appearing: a panel, a dropdown
300–500ms   a large element crossing the screen
> 500ms     the user is now waiting for your animation

Under about 100ms reads as instant, which is fine and sometimes correct. Over 300ms for anything interactive starts to feel slow, and a hover effect at 500ms feels broken because your mouse has already left.

Timing functions

ease-out                      /* fast then slow — for things ENTERING */
ease-in                       /* slow then fast — for things LEAVING */
ease-in-out                   /* for things moving between two points */
linear                        /* for continuous motion: a spinner, a progress bar */
cubic-bezier(0.34, 1.56, 0.64, 1)   /* a slight overshoot */

ease-out for most things. The default is ease, which is close enough, but ease-out is the one that feels responsive — it starts immediately and settles, so the interface appears to react instantly.

ease-in on its own is the wrong default because it starts slowly, which reads as lag.

And linear for anything that loops, because an eased loop stutters at the seam.

What is cheap to animate, and what is not

The one performance rule, and it matters more than everything else in this lesson.

The browser renders in stages: style → layout → paint → composite. Which properties you animate decides how many of those stages run on every frame — sixty times a second.

Cheap — composite only:

transform      /* translate, scale, rotate */
opacity
filter         /* moderately cheap */

These can be handled by the GPU without touching layout or paint.

Expensive — triggers layout on every frame:

width, height, top, left, right, bottom
margin, padding
font-size

Animating width means the browser recalculates the position of everything affected, sixty times a second. On your laptop it looks fine. On a mid-range Android it drops frames visibly.

So:

/* expensive */
.panel { left: -300px; transition: left 250ms; }
.panel.open { left: 0; }

/* cheap — same visual result */
.panel { transform: translateX(-100%); transition: transform 250ms; }
.panel.open { transform: translateX(0); }

Move things with transform: translate, not left/top. Resize with transform: scale, not width/height. That single substitution is most of web animation performance.

You can watch the difference: devtools' Performance panel, record while the animation runs, and look for purple "Layout" bars. A cheap animation has none.

Two related notes. will-change: transform hints that something will animate so the browser can prepare — use it sparingly and remove it afterwards, because it costs memory and creates a stacking context (module 3's z-index lesson). And a transform on an ancestor breaks position: fixed inside it, from the same lesson.

Animating from nothing

The problem: you cannot transition display: none to display: block, because display is not an animatable property. So a panel appears instantly and then fades, or the fade never happens.

The modern answer, and it is genuinely new:

.panel {
  transition: opacity 200ms, transform 200ms, display 200ms allow-discrete;
  opacity: 0;
  transform: translateY(-8px);
  display: none;
}

.panel.open {
  opacity: 1;
  transform: translateY(0);
  display: block;
}

@starting-style {
  .panel.open { opacity: 0; transform: translateY(-8px); }
}

Three pieces: allow-discrete lets display flip at the right moment rather than immediately, @starting-style gives the browser a state to animate from on first appearance, and the rest is a normal transition. Check support on caniuse.com; the fallback is that it appears without animating, which is acceptable.

The older approach — visibility plus opacity, with a delay on the way out — still works and is what you will find in existing code.

Keyframe animations

For anything that is not a simple A-to-B:

@keyframes fade-up {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}

.card {
  animation: fade-up 300ms ease-out both;
}
animation: <name> <duration> <timing> <delay> <iteration-count> <direction> <fill-mode>;
animation: spin 1s linear infinite;
animation: fade-up 300ms ease-out 100ms 1 normal both;

both as the fill mode is the one to remember: it applies the from state before the animation begins and holds the to state after it ends. Without it the element flickers to its unanimated state at both ends.

A spinner, which is the one animation nearly every project needs:

@keyframes spin { to { transform: rotate(1turn); } }

.spinner {
  animation: spin 800ms linear infinite;
}

Note to only — the from is implied by the current value, which is less to write.

Staggering

.card { animation: fade-up 300ms ease-out both; }
.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 60ms; }
.card:nth-child(3) { animation-delay: 120ms; }

Cards appearing one after another. Keep the increment small — 50 to 80ms — and cap the total. Ten cards at 100ms each means the last one appears a second after the first, and the visitor is waiting for your effect.

Restraint, which is most of the lesson

Animation should tell the user something. Three legitimate jobs:

Feedback — the thing you pressed responded. A 100ms colour change on :active does more for perceived quality than anything else on this page.

Continuity — a panel slides from the button that opened it, so the relationship is obvious.

Attention, sparingly — a newly added item fades in so you notice it.

Everything else is decoration, and decoration has a cost paid by the person on the slow phone.

The specific things to avoid:

Animating on scroll. Content that fades in as you scroll means content that is missing until you scroll — bad on a slow connection, worse for a screen reader, and actively unpleasant on a trackpad.

Anything that delays reading. A hero that animates in over 800ms is 800ms of nobody being able to read your page.

Looping animation near text. It pulls the eye away continuously, and for some readers it makes the text genuinely hard to follow.

Parallax. It is the single most common trigger for vestibular discomfort.

Autoplaying carousels. Content that moves away while being read.

And the hard rule: nothing may flash more than three times per second. That is WCAG 2.3.1, it is a Level A criterion, and the reason is that it can trigger seizures.

Reduced motion, again

From module 4, because it belongs here too. The better pattern is to make motion opt-in:

@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 200ms ease-out;
  }
  .card:hover { transform: translateY(-2px); }
}

No motion exists unless the visitor has expressed no objection, so there is nothing to remember to switch off. And the safety net for everything you did not write that way:

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

0.01ms rather than 0, because zero can stop transitionend firing and hang JavaScript waiting on it.

Reduce does not always mean remove. The criterion is about vestibular triggers — large movement, parallax, zoom, spin. A fade is usually fine, so keeping the opacity change and dropping the slide is often the better answer than switching everything off.

A view transition, briefly

@view-transition { navigation: auto; }

One declaration, and navigating between pages of your site cross-fades instead of flashing white. Support is still arriving — check caniuse.com — and the fallback is the normal instant navigation, so it costs nothing to add. Worth knowing it exists.

Check your work

Where the transition goes. On the base state, not on :hover.

Why not transition: all. It animates things you did not intend and costs more to compute.

Durations. 75–150ms for a state change, 200–300ms for something appearing, and over 500ms the visitor is waiting.

Which timing function to default to. ease-out, because it starts immediately.

Why linear for loops. An eased loop stutters at the seam.

The four cheap things to animate. transform, opacity, and filter with care.

What makes an animation expensive. Anything that triggers layout — width, height, top, left, margin, padding, font-size.

The substitution that fixes most performance. transform: translate instead of left/top; scale instead of width/height.

How to check. The Performance panel — look for purple Layout bars.

Two costs of will-change. Memory, and it creates a stacking context.

The three pieces for animating from display: none. allow-discrete, @starting-style, and a normal transition.

Why both as a fill mode. Otherwise the element flickers to its unanimated state at both ends.

Animation's three legitimate jobs. Feedback, continuity, and attention used sparingly.

Five things to avoid. Scroll-triggered reveals, anything delaying reading, looping motion near text, parallax, autoplaying carousels.

The hard rule. Nothing flashes more than three times a second — WCAG 2.3.1, Level A.

Why 0.01ms rather than 0. Zero can prevent transitionend and hang JavaScript.

Practice

  1. Put a transition on a :hover rule only. Hover and leave, and describe what happens. Move it to the base state.
  2. Write transition: all 200ms on an element, then change three properties. Then name only the one you meant.
  3. Try the same hover at 75ms, 200ms and 600ms. Pick one and say why.
  4. Compare ease, ease-out, ease-in and linear on the same movement.
  5. Animate a spinner with ease-in-out and watch the seam. Then linear.
  6. Slide a panel with left, record it in the Performance panel, and count the Layout bars. Then do it with translateX and compare.
  7. Do the same with width versus scaleX.
  8. Throttle the CPU to 4× and compare both versions again. This is the real test.
  9. Add will-change: transform, then check whether it created a stacking context by putting a z-indexed child inside it.
  10. Transition from display: none without allow-discrete, then with it and @starting-style.
  11. Write a keyframe animation without both and watch both ends flicker.
  12. Stagger six cards at 60ms, then at 200ms. Time how long until the last one appears.
  13. Build a scroll-triggered fade-in, then load the page with JavaScript disabled or on a throttled connection.
  14. Move all your motion inside (prefers-reduced-motion: no-preference).
  15. Keep a fade and drop a slide under reduce, rather than removing both.
  16. Add @view-transition { navigation: auto } to a two-page site and navigate between them.

Official documentation

Next: getting it online.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship