CSS animation can add feedback, explain a state change, or guide attention without adding a JavaScript animation library. The important distinction is whether you are animating a change between two states or describing a sequence of steps. Choosing the right technique keeps the CSS easier to maintain and gives you a clear place to handle reduced motion.
This guide covers practical CSS animation techniques with complete examples: transitions for interactive states, keyframes for multi-step motion, reusable custom properties, loading indicators, and accessible motion controls. The examples are deliberately small so you can adapt them to a website or a small web application.
If you are also working on layout, responsive behavior, or other current CSS features, see our guide to modern CSS techniques for web developers.
Start with the right kind of animation
Use a transition when a property changes from one value to another because of a state such as :hover, :focus-visible, :checked, or a class added by your application. A transition describes how the browser should interpolate that change.
Use an animation with @keyframes when the effect needs several stages, repeats, or a timeline that is not tied to one state change. A keyframe animation can also run once when an element enters the page or starts with a class.
Neither technique should be used to hide important information. A button should remain understandable without a hover effect, and a loading animation should have a text status for people who cannot see or perceive the motion.
1. Use transitions for buttons and links
A button usually needs a short transition between its normal, hover, and keyboard-focus states. Animate the visual change, not the meaning of the control.
<button class="action-button" type="button">Save changes</button>
.action-button {
background: #175cd3;
border: 2px solid #175cd3;
border-radius: 0.5rem;
color: #ffffff;
cursor: pointer;
font: inherit;
font-weight: 700;
padding: 0.75rem 1rem;
transition:
background-color 160ms ease,
border-color 160ms ease,
transform 160ms ease;
}
.action-button:hover {
background: #1249a8;
border-color: #1249a8;
transform: translateY(-2px);
}
.action-button:active {
transform: translateY(0);
}
.action-button:focus-visible {
outline: 3px solid #fdb022;
outline-offset: 3px;
}
Keep the focus indicator visible even when the button has other motion. :focus-visible avoids adding the same outline to every pointer click while preserving a useful keyboard focus style. The small vertical movement is optional; the button should still be clear if it is removed.
Avoid using transition: all. It can animate properties that you did not intend to change and makes later layout edits harder to reason about. List the properties that are meant to move, and use a duration short enough that the control still feels responsive.
2. Animate an underline without moving the layout
A link underline can grow from one side by animating a pseudo-element. The link remains in the document flow, so the effect does not change the position of neighboring content.
<a class="animated-link" href="/modern-css-techniques-for-web-developers/">
Read the modern CSS guide
</a>
.animated-link {
color: #175cd3;
display: inline-block;
font-weight: 700;
position: relative;
text-decoration: none;
}
.animated-link::after {
background: currentColor;
content: "";
height: 2px;
inset-block-end: -0.2rem;
inset-inline-start: 0;
position: absolute;
transform: scaleX(0);
transform-origin: left;
transition: transform 180ms ease;
width: 100%;
}
.animated-link:hover::after,
.animated-link:focus-visible::after {
transform: scaleX(1);
}
The inset-block-end and inset-inline-start properties describe the underline in logical directions. That makes the rule easier to adapt when a layout uses a different writing direction. Keep a normal text-decoration or another clear non-animated treatment when the link is not in an interactive state; an underline that appears only on hover is not enough for every context.
3. Use keyframes for a multi-step entrance
Keyframes are useful when an element needs an initial state, a short movement, and a final state. Apply the animation with a class so the component can be shown without motion and so JavaScript can control when the effect starts if necessary.
<article class="notice notice--entering">
<h2>Project saved</h2>
<p>Your changes are ready for the next step.</p>
</article>
.notice {
background: #eff8ff;
border: 1px solid #84caff;
border-radius: 0.75rem;
color: #12344d;
padding: 1rem;
}
.notice--entering {
animation: notice-enter 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes notice-enter {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
both applies the animation's first and last styles outside the active interval. That prevents a flash of the unanimated state while the animation is starting and keeps the final state after it ends. If the notice contains essential content, render it in a usable state first and treat the animation as an enhancement.
Do not add a large delay to every element in a page. A delayed element can look broken to someone who is waiting for content, and long sequences are difficult to use on slow devices. If several items need a small stagger, use a custom property with a bounded value:
.list-item {
animation: notice-enter 360ms ease both;
animation-delay: var(--item-delay, 0ms);
}
.list-item:nth-child(2) {
--item-delay: 50ms;
}
.list-item:nth-child(3) {
--item-delay: 100ms;
}
For a generated list, set the custom property from your rendering code only after validating the value. A predictable maximum is easier to maintain than an indefinitely increasing delay.
4. Build a loading indicator with a text alternative
A spinner communicates that an operation is in progress, but it does not explain what is happening by itself. Give it a status element with text, and do not make the animation the only indication of progress.
<div class="loading-status" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span>Saving your changes…</span>
</div>
.loading-status {
align-items: center;
display: inline-flex;
gap: 0.5rem;
}
.spinner {
animation: spin 800ms linear infinite;
border: 0.2rem solid #d0d5dd;
border-radius: 50%;
border-top-color: #175cd3;
height: 1.25rem;
width: 1.25rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
aria-hidden="true" keeps the decorative spinner out of the accessibility tree while the status text remains available. In a real application, update the text to a success or error message when the operation finishes. If the operation can take a long time, provide a way to cancel it or continue elsewhere instead of requiring someone to watch the indicator.
5. Respect reduced-motion preferences
Some visitors request less motion through their operating-system or browser settings. The prefers-reduced-motion media feature lets you reduce or remove non-essential movement without removing the information conveyed by the interface.
Put the override next to the animations it controls, or keep one clearly named accessibility layer:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
This common reset is a useful baseline, but it is not a substitute for judging each effect. A transition that conveys a menu opening may need a short opacity change rather than a sudden disappearance. A continuously rotating spinner can be replaced by a static indicator while its status text remains. Do not remove focus styles or state changes when reducing motion.
You can also write the component with the reduced-motion decision explicit:
.progress-indicator {
animation: pulse 1.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.progress-indicator {
animation: none;
}
}
The MDN documentation for prefers-reduced-motion explains the media feature and links to browser compatibility details. Test both settings; do not assume that a reduced-motion preference means every transition must be removed.
6. Choose properties that do not create unnecessary layout work
Animation can change many CSS properties, but they do not all have the same cost. Changing a size, position in normal flow, or other layout-related property can cause surrounding content to be recalculated. Prefer an effect that visually uses transform or opacity when that expresses the design accurately, and measure more complex effects in the browser’s performance tools.
This does not mean that transform and opacity are automatically free, or that every animation should be forced onto its own compositor layer. Avoid adding will-change everywhere: it is a hint for a known upcoming change, not a general performance switch. Start with a simple rule, inspect the result on a realistic device, and remove the hint when it is no longer needed.
The MDN guide to using CSS animations documents the keyframe model, timing properties, and animation events. The CSS Animations specification defines the underlying behavior. For a broad CSS foundation, our modern CSS techniques guide covers layout and progressive enhancement patterns that work alongside these effects.
7. Make responsive animation choices
Motion should not assume a large screen or a fast pointer. Check these cases:
- On a narrow screen, make sure an animated element cannot overflow because of a translation or scaled decoration.
- Keep tap targets and readable text stable; do not rely on a hover-only explanation.
- Avoid parallax or large movement when a small fade communicates the same state.
- Test with zoom, increased text size, keyboard navigation, and touch input.
- Keep animation delays short enough that essential content appears immediately.
For a card grid, animate the card itself rather than using a large left or top change that can disturb the layout. When responsive CSS changes the component’s size, inspect the start and end states at each breakpoint. The animation should be an enhancement to the responsive layout, not a reason to add a new breakpoint.
Common CSS animation mistakes
Animating only :hover
Hover is unavailable to keyboard and touch users. Pair hover styles with :focus-visible where the effect communicates an interactive state, and make the default presentation understandable on its own.
Hiding content until an animation runs
If a script fails or a user prefers reduced motion, content hidden by opacity: 0 can remain inaccessible or appear missing. Render meaningful content in the final state, then add an entering class or a progressive enhancement.
Forgetting animation cleanup
An infinite animation keeps consuming resources and can distract users. Use animation-iteration-count: 1 for one-time effects, stop a loading animation when the operation ends, and remove classes or listeners that are no longer needed.
Changing layout to create a visual effect
Animating width, height, or a flow position can make nearby content jump. If a transform can express the same movement, use it and verify the result. If the design really requires a layout change, keep the affected area small and test it with realistic content.
Ignoring the browser support boundary
CSS animations, transitions, keyframes, and prefers-reduced-motion are widely available, but individual properties and newer timing features vary. Check the compatibility tables for the exact feature you use rather than relying on the age of the syntax. The MDN animation reference is a useful starting point.
A practical checklist
Before shipping a CSS animation, check:
- Does the effect communicate a state, relationship, or action instead of adding decoration only?
- Is a transition or a keyframe animation the simpler model?
- Is the content usable before and after the animation?
- Can keyboard and touch users access the same information?
- Does the component respond to
prefers-reduced-motion? - Are the animated properties intentional and limited?
- Does the effect behave at narrow widths, zoom, and slower devices?
- Have you checked compatibility for every feature used?
CSS animation works best when it supports a clear interaction. Start with a static, accessible component, add a small transition or keyframe sequence, and then add the reduced-motion and responsive rules before polishing the timing. That order gives you an interface that remains useful even when motion is unavailable.