Module 7: Visual Effects, Transitions & Animations
Keyframe Animations: @keyframes and the animation Properties
Defining multi-step animations, each animation property in detail, fill-mode and direction, when to pick animation over transition, and how to keep it accessible.
A transition needs a trigger and goes from one state to another. A keyframe animation runs on its own, can have many steps, and can loop. It has two parts: the keyframes, which describe what happens, and the animation properties, which describe how it runs on an element.
Defining the keyframes#
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
For more than two steps, use percentages:
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
from is the same as 0% and to is the same as 100%. The browser fills in every frame between the steps. If you omit the start or end keyframe, it uses the element's current computed value.
Applying it#
.card {
animation-name: fade-in-up;
animation-duration: 400ms;
animation-timing-function: ease-out;
animation-delay: 100ms;
animation-iteration-count: 1;
animation-direction: normal;
animation-fill-mode: both;
animation-play-state: running;
}
As a shorthand:
.card { animation: fade-in-up 400ms ease-out 100ms 1 normal both; }
As with transition, the first time value is the duration, the second is the delay. The name is the only thing you cannot leave out.
Each property, in more detail#
animation-iteration-count: a number, orinfinite.animation-direction:normal,reverse,alternate(forwards, then backwards on alternating runs) oralternate-reverse.alternatewithinfinitegives a smooth back-and-forth with no jump at the end of each loop.animation-timing-function: applies between each pair of keyframes, not across the whole animation. A bouncing ball withease-inandease-outset inside the keyframes needs to be different for each leg.animation-play-state:runningorpaused. You can pause an animation on hover, or with a class from JavaScript.steps(): jumps between frames rather than interpolating. It is the technique for sprite sheets and typewriter effects.
Fill mode#
Before and after it runs, an animation does not affect the element, so the element snaps back to its normal style. animation-fill-mode changes that.
| Value | Behavior |
|---|---|
none | applies nothing before the start or after the end |
forwards | keeps the last keyframe's values after the end |
backwards | applies the first keyframe's values during the delay |
both | does both |
The classic bug: you set a delay on a fade-in, and the element appears at full opacity during the delay, then vanishes and fades in. It needs backwards (or both), so the opacity: 0 from the first keyframe applies while waiting.
.item { animation: fade-in-up 500ms ease-out 300ms both; }
Animation versus transition#
| Need | Use |
|---|---|
| A change between two states, triggered by an event | transition |
| An effect that runs on load or loops | animation |
| Several steps, or the path matters | animation |
| It must reverse smoothly if interrupted | transition |
Transitions handle interruptions better. If a user leaves the hover midway, the transition reverses from its current point. A hover-triggered animation restarts or snaps.
Practical patterns#
Loading spinner#
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #0b5fff;
border-radius: 50%;
animation: spin 800ms linear infinite;
}
linear matters here. With an easing curve, the spinner would speed up and slow down at each rotation.
Staggered entrance#
Give each item its own delay with a custom property:
<li style="--i: 0">One</li>
<li style="--i: 1">Two</li>
<li style="--i: 2">Three</li>
li {
animation: fade-in-up 400ms ease-out both;
animation-delay: calc(var(--i) * 80ms);
}
Skeleton loading shimmer#
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #eee 25%, #f8f8f8 50%, #eee 75%) 0 0 / 200% 100%;
animation: shimmer 1.5s linear infinite;
}
Performance#
The same pipeline logic as for transitions applies. Animating transform and opacity is cheap. Animating width, height, top or box-shadow forces layout or paint every frame.
Infinite animations deserve extra thought. An offscreen element that keeps animating still costs battery. Pause or remove animations on elements the user cannot see.
Do not animate large blur effects, such as filter: blur(40px) on a large element, unless you have measured it.
Reacting from JavaScript#
The browser fires events at the key moments:
el.addEventListener("animationend", () => el.remove());
el.addEventListener("animationiteration", () => { /* each loop */ });
This is how you remove a toast after it fades out, with CSS handling the motion and JavaScript only reacting.
To restart an animation, you must remove the class, force a reflow, and add it back, or use the Web Animations API (el.animate(...)), which gives finer control from script.
Scroll-driven animations#
Newer CSS lets you link an animation's progress to scroll position with animation-timeline, so you can build progress bars and reveal effects without JavaScript. Support is growing but not universal, so use it as a progressive enhancement and check the current compatibility tables before depending on it.
Accessibility#
@media (prefers-reduced-motion: reduce) {
.spinner, .card { animation: none; }
}
Be careful with a blanket animation: none. A loading spinner is a legitimate signal that something is in progress. For essential indicators, replace motion with something static or a slower, gentler effect. Also avoid flashing content more than three times per second, which can trigger seizures.
Common mistakes#
- No
fill-modewith a delay, so the element flashes before the animation. - Reading
animation-timing-functionas global when it applies between keyframes. - Looping animations that never stop, draining battery.
- Animating layout properties like
widthandtop. - Writing the delay as the first time value and getting a delay instead of a duration.
- Using animation where a transition is enough.
- Missing
prefers-reduced-motion.
Practice#
- Build a fade-in-up entrance for a list of five items with staggered delays using
--i. Make sure nothing flashes before it starts. - Make a loading spinner and a pulsing dot. Check the spinner with
linearandeaseto see the difference. - Create a typewriter effect with
steps()andwidthinchunits. - Write a reduced-motion version of each animation you built. Decide which ones to disable completely and which to simplify, and justify the decision.