Animations.
The details that make sites feel polished.
Two sites can have the same layout, colors, and typography — and one feels premium while the other feels cheap. The difference is often motion. Buttons that lift on hover, cards that fade in, loaders that spin, modals that slide. CSS animations are how you add this layer. This is the final lesson in the track — and the one that separates "competent" from "polished." Every example has a live preview you can interact with.
Transitions animate a property from one value to another when it changes (hover, focus, class toggle). Animations use @keyframes for multi-step, looping, or auto-playing motion. Transform moves, scales, or rotates elements without affecting layout — it's GPU-accelerated and fast. Animate only transform and opacity for smooth 60fps motion. Always wrap motion in @media (prefers-reduced-motion: no-preference) for accessibility.
Before this lesson: complete Lesson 6. You should be comfortable with all the layout fundamentals.
🎬 Transitions — animate state changes
The simplest animation. Two states, one property, done.
/* The transition property — set once, applies to any changes */
.button {
background: #1572B6;
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
/* Transition all changes in 0.2s with ease timing */
transition: background-color 0.2s ease, transform 0.2s ease;
}
/* On hover, change the value — transition animates it */
.button:hover {
background: #33A9DC;
transform: translateY(-2px);
}
/* On click, move back down */
.button:active {
transform: translateY(0);
}
/* Multiple properties with different durations */
.card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
transition:
transform 0.3s ease,
box-shadow 0.3s ease,
border-color 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
/* The transition doesn't exist on :hover — it's on the base state.
That way, leaving hover also animates back smoothly. */
Hover the button and card to see the transitions:
Card with hover transition
Hover to see the lift and shadow effect.
transition-property
Which CSS properties should animate. Use specific properties (background-color, transform) or all. Avoid all — it animates unintended changes.
transition-duration
How long the transition takes. 0.2s for fast UI feedback, 0.3s for cards, 0.5s for larger elements. Under 100ms feels instant; over 500ms feels slow.
transition-timing-function
The speed curve. ease (default), ease-out (settles), ease-in (accelerates), linear (constant). Use ease-out for entrance effects.
transition-delay
Wait before starting. Rarely used, but useful for staggering multiple elements. transition: opacity 0.3s ease 0.1s; — starts after 100ms.
Put the transition on the base state, not on :hover. If you write .button:hover { transition: ... }, the transition only applies when hovering in — leaving the hover has no transition. If you write .button { transition: ... } on the base, transitions apply both entering AND leaving the hover state. This one rule is the difference between "hover works" and "hover feels right."
⏱️ Timing functions
How speed changes over the duration. The difference between "feels natural" and "feels robotic."
/* The five built-in timing functions /
.ease { transition: transform 1s ease; } / default — slow, fast, slow /
.ease-in { transition: transform 1s ease-in; } / slow start, accelerate /
.ease-out { transition: transform 1s ease-out; } / fast start, slow end /
.ease-in-out { transition: transform 1s ease-in-out; } / slow at both ends /
.linear { transition: transform 1s linear; } / constant speed */
/* cubic-bezier — custom curves for fine control /
.snappy {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
/ Material Design's standard "ease-out" curve */
}
.bouncy {
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
/* Overshoots the target then settles — feels playful */
}
.smooth {
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
/* Very smooth — used by Stripe and Vercel */
}
/* Hover to animate */
.box {
width: 100px;
height: 100px;
background: #1572B6;
border-radius: 8px;
cursor: pointer;
}
.box:hover {
transform: translateX(200px);
}
ease (default)
Slow start, fast middle, slow end. Natural for UI — feels like real objects. The default and a safe choice for most transitions.
ease-out
Fast start, slow end. Use for entrance animations — the element "arrives" and settles. Also good for hover states. The most-used non-default function.
ease-in
Slow start, fast end. Use for exit animations — the element "leaves" and accelerates away. Less common than ease-out.
cubic-bezier
Custom curves for fine control. Copy the curves from Material Design or Vercel — they've spent years tuning them. cubic-bezier(0.4, 0, 0.2, 1) is Material's standard.
Use cubic-bezier(0.16, 1, 0.3, 1) for premium feel. This is the curve Stripe, Vercel, and Linear use for almost every animation. It starts fast and settles very smoothly — feels "expensive" in a way that ease doesn't. One line of CSS can transform how professional a site feels. Test it side by side with ease and the difference is instant.
🔄 Transforms — move, scale, rotate
The properties that animate without triggering layout. GPU-accelerated and fast.
/* translate(x, y) — move without affecting layout /
.translate {
transform: translate(20px, 10px);
/ Same as translateX(20px) translateY(10px) */
}
/* The centering trick — the classic use /
.centered {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
/ Because translate moves by the element's OWN width/height */
}
/* scale(n) — grow or shrink /
.scale-up {
transform: scale(1.1); / 10% larger /
}
.scale-down {
transform: scale(0.9); / 10% smaller */
}
/* rotate(deg) — rotate around center */
.rotate {
transform: rotate(45deg);
}
/* Multiple transforms — space-separated, applied in order /
.combined {
transform: translate(-50%, -50%) rotate(15deg) scale(1.2);
/ Applied left to right */
}
/* transform-origin — where transforms pivot from /
.rotate-top-left {
transform: rotate(45deg);
transform-origin: top left;
/ Default is center; this rotates from top-left */
}
/* The hover-lift pattern — the most common card effect */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-8px) scale(1.02);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
translate
scale
rotate
combined
Hover each box to see the transform. Notice how they don't affect surrounding layout — they just move visually.
translate
Move without layout impact. The most common transform. Use translate(-50%, -50%) for perfect absolute centering — the classic trick.
scale
Grow or shrink. scale(1.1) = 10% larger. scale(0.9) = 10% smaller. Use scale(1.02) for subtle hover effects — bigger feels cartoonish.
rotate
Rotate in degrees. rotate(45deg). Use transform-origin to change the pivot point. Rarely used on its own, often combined with others.
transform-origin
Where transforms pivot from. Default is center. top left, bottom right, or coordinates. Useful for effects like "grow from bottom" or "rotate from corner."
Never animate top or left — use transform: translate() instead. Animating top or left forces the browser to recalculate the layout of every element on every frame — even if only one element visually moves. This causes jank (dropped frames), especially on mobile. transform: translate() is GPU-accelerated and doesn't trigger layout. Same visual result, 10x faster. This one rule is the difference between smooth 60fps animations and choppy ones.
🎞️ Keyframes — multi-step animations
For anything more than two states, or anything that plays without a trigger.
/* The @keyframes rule — define the animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Multi-step — percentages are the "frames" */
@keyframes bounce {
0% { transform: translateY(0); }
50% { transform: translateY(-20px); }
100% { transform: translateY(0); }
}
/* Fade in with slide — the entrance animation */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Pulse — for loading or attention effects */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Shimmer — for skeleton loaders */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
/* Apply the animation */
.spinner {
width: 40px;
height: 40px;
border: 4px solid #334155;
border-top-color: #1572B6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.bouncy-ball {
width: 30px;
height: 30px;
background: #1572B6;
border-radius: 50%;
animation: bounce 1s ease-in-out infinite;
}
.fade-in {
animation: fadeInUp 0.6s ease-out forwards;
/* forwards = keep the final state after animation ends */
}
.pulsing {
animation: pulse 2s ease-in-out infinite;
}
/* Multiple animations on one element /
.multi {
animation:
fadeInUp 0.6s ease-out forwards,
pulse 3s ease-in-out 0.6s infinite;
/ Second animation starts after the first (delay 0.6s) */
}
spin
bounce
pulse
These three animations loop infinitely. animation: name duration timing-function iteration-count
@keyframes name
Defines the animation steps. from and to for two steps; 0%, 50%, 100% for multi-step. Name it descriptively — spin, bounce, fadeInUp.
animation shorthand
animation: name duration timing iteration direction;. Example: animation: spin 1s linear infinite;. All parts optional except name and duration.
iteration-count
How many times to play. infinite loops forever. A number plays that many times. 2 plays twice then stops. Default is 1.
animation-fill-mode
What to do before/after the animation. forwards keeps the last frame's state. backwards applies the first frame during any delay. both does both. Use forwards for entrance animations.
The spinner is the only keyframe animation most sites need. Loading spinner, plus maybe a "fade in" for entrance. 90% of UI motion is transitions — hover effects, button clicks, focus states. Animations are for the rare cases where you need looping or multi-step motion without a trigger. Don't over-animate. Every animation you add is one more thing to maintain, one more thing to test for accessibility, and one more thing that could distract users from the actual content.
🎯 Real-world patterns
Five animations that cover most of what you'll need. Each is interactive.
/* 1. BUTTON HOVER — the most common animation */
.btn {
background: #1572B6;
color: white;
padding: 12px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
background: #33A9DC;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(21, 114, 182, 0.4);
}
.btn:active {
transform: translateY(0);
}
/* 2. CARD LIFT — the hover effect on cards */
.card {
background: #1e293b;
border-radius: 12px;
padding: 24px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
/* 3. LOADING SPINNER — the most-used keyframe animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: #1572B6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 4. FADE IN UP — page entrance animation */
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeInUp 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* 5. SKELETON LOADER — shimmer effect for loading states */
@keyframes shimmer {
0% { background-position: -468px 0; }
100% { background-position: 468px 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#1e293b 0%,
#334155 50%,
#1e293b 100%
);
background-size: 936px 100%;
animation: shimmer 1.5s linear infinite;
border-radius: 6px;
}
1. Button hover
2. Card lift
Hover me
I lift on hover
3. Loading spinner
4. Skeleton loader
Restraint is the mark of good motion design. The best animated sites have subtle motion that you barely notice — 200ms hovers, 300ms transitions, gentle scales. The worst ones have everything bouncing, sliding, and pulsing at once. Rule of thumb: if a user would notice the animation, it's either too long, too big, or unnecessary. Motion should support usability, not decorate it. The five patterns above are all you need for 90% of UIs.
♿ Accessibility — prefers-reduced-motion
Motion can physically harm users. This is how you respect that.
/* The pattern: only animate if user hasn't requested reduced motion */
@media (prefers-reduced-motion: no-preference) {
.animated-element {
animation: bounce 1s ease infinite;
}
.transitions {
transition: transform 0.3s ease;
}
}
/* The alternative: disable animations for users who requested it */
@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 universal approach — use this in every project */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* For essential animations (loading spinner), keep but reduce /
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 2s; / slow it down */
}
}
/* Why this matters:
• Vestibular disorders (dizziness, vertigo, nausea) affect ~35% of adults over 40
• Large moving backgrounds, parallax scrolling, and infinite animations
can trigger symptoms
• Users who set "Reduce motion" in their OS are telling you
they need this
• WCAG 2.1 AA requires respecting this setting
• It costs 6 lines of CSS
*/
The prefers-reduced-motion media query detects the user's OS setting. Here's what that setting looks like on different systems:
Enable "Reduce motion" on:
- macOS: System Settings → Accessibility → Display → Reduce motion
- Windows: Settings → Accessibility → Visual effects → Animation effects
- iOS: Settings → Accessibility → Motion → Reduce Motion
- Android: Settings → Accessibility → Remove animations
When enabled, your CSS media query triggers and animations are reduced. It's a small cost that has a real impact on users who need it.
Why this matters
Vestibular disorders — dizziness, vertigo, nausea — affect ~35% of adults over 40. Large animations can trigger episodes. Users who enable "reduce motion" in their OS are telling you their needs.
WCAG requirement
WCAG 2.1 AA success criterion 2.3.3 requires that motion-triggered interaction can be disabled. This isn't optional — it's a legal requirement in many jurisdictions.
The universal reset
The 6-line @media (prefers-reduced-motion: reduce) reset covers every animation in your project. Copy it into every CSS file — one line of defense for every future animation.
Exceptions
Loading spinners are usually fine to keep — they convey important state. Reduce their speed rather than removing them. Same for progress indicators. Disable decorative motion, keep functional motion.
Don't skip prefers-reduced-motion. If your site has any auto-playing animation, parallax effect, or large motion, you're excluding users with vestibular disorders. This isn't a nice-to-have — it's WCAG 2.1 AA, and it's the difference between a site that works for everyone and one that makes some users physically ill. The fix is 6 lines of CSS. There's no excuse.
🏆 Challenge
Three tasks. Each exercises a different animation skill.
Task 1 — Polish a button
Take any button on a page you've built. Add these animations:
1. Hover: background gets slightly lighter + lifts 2px + soft shadow
2. Active (click): returns to original position
3. Focus: visible outline appears (for accessibility)
4. All transitions use 0.2s ease-out
Test all four states by hovering, clicking, and tabbing. The button should feel responsive to every interaction. This is the pattern every real button uses.
Task 2 — Loading spinner
Build a loading spinner using only CSS:
1. Circle with a border (e.g., 40px diameter, 4px border)
2. Border is gray except for one side which is blue
3. Rotates infinitely
4. Animation is smooth and linear
5. Slower if prefers-reduced-motion: reduce
Then build a second version: a "pulse" animation — a dot that fades in and out. Both should feel different but both should work as loading indicators.
Task 3 — Entrance animation
Build a page with 3 cards that animate in on page load:
1. Card 1 fades in + slides up (starts first)
2. Card 2 does the same, 100ms later
3. Card 3 does the same, 200ms later
4. All cards stay visible after animation
5. Animation is skipped if prefers-reduced-motion: reduce
Hint: use animation-delay with different values for each card, or use transition-delay. Use animation-fill-mode: forwards so the cards stay visible after animating.
⚠️ Common mistakes
The animation bugs that catch every beginner.
These force layout recalculation on every frame — jank, especially on mobile. Fix: use transform: translate() for movement and transform: scale() for sizing. GPU-accelerated, 60fps guaranteed.
transition: all animates every property — including ones you didn't mean to change. Fix: list specific properties — transition: transform 0.3s ease, background 0.2s ease.
.btn:hover { transition: ... } only animates when entering hover — leaving is instant. Fix: put transition on .btn (base), not on :hover.
Under 100ms feels instant (no perceived animation). Over 500ms feels sluggish. Fix: 200ms for micro-interactions, 300ms for cards, 500ms max for entrance effects.
Everything animates at once — the page feels chaotic. Fix: stagger animations with animation-delay, or only animate on interaction (hover, scroll, click).
Users with vestibular disorders get sick from your animations. Fix: 6 lines of CSS at the top of every file. It's WCAG required, not optional.
Every element bouncing, sliding, and pulsing looks chaotic and amateur. Fix: animate sparingly — hover states, entrance effects, and loading indicators. That's it. Minimalism wins.
Entrance animations end and the element reverts to its original state (invisible). Fix: animation-fill-mode: forwards — keeps the final state after the animation ends.
🎓 CSS track complete
Seven lessons. One complete skillset. Here's what you can do now.
You can build any layout
Box model, flexbox, grid, responsive design. Every "how do I arrange these elements" problem is now solvable with confidence.
You can style professionally
Colors, typography, spacing — the three pillars of visual design. Your pages look designed, not default.
You can animate
Transitions, transforms, keyframes, accessibility. The polish layer that separates "competent" from "premium" — you have it.
You're ready for anything
Frameworks (Tailwind, Bootstrap), preprocessors (Sass), and component libraries all build on this foundation. You can learn them in weeks, not months.
What to build before moving on. Build a complete landing page — hero, features, testimonials, footer, all responsive, all with polished hover states and entrance animations. Ship it. That one project will teach you more than the next ten tutorials — because you'll have to solve real problems, not follow curated examples. Once it's done, you're ready for JavaScript frameworks or continued JavaScript practice.
➡️ What's next
You've finished the CSS track. Three directions from here.
❓ Frequently asked questions
What is the difference between CSS transitions and CSS animations?
Transitions animate a property from one value to another when that value changes — usually triggered by hover, focus, or a class change. They're simple: two states, one duration. Animations use @keyframes to define multiple steps and can loop, reverse, and play automatically without a trigger.
What properties should I animate in CSS?
Animate transform and opacity. They're GPU-accelerated and don't trigger layout recalculation, so they stay at 60fps. Avoid animating width, height, top, left, margin, and padding — these force the browser to recalculate layout on every frame, causing jank.
What does transform: translate() do?
transform: translate(x, y) moves an element without affecting layout. Unlike margin or position, translate doesn't push siblings around — the element visually shifts but occupies its original space. Use translate(-50%, -50%) to center an absolutely positioned element.
How do I write CSS keyframes?
Use @keyframes name { from { ... } to { ... } } for a simple two-state animation, or add percentage stops for multi-step animations. Then apply with animation: name duration timing-function. Keyframes are the CSS equivalent of a storyboard.
What is prefers-reduced-motion and why does it matter?
prefers-reduced-motion is a media query that detects whether the user has requested reduced motion in their OS settings. This helps users with vestibular disorders who can get physically sick from animations. Always wrap motion-heavy animations in @media (prefers-reduced-motion: no-preference) or provide a fallback with @media (prefers-reduced-motion: reduce). It's required for WCAG 2.1 AA compliance.
What is the difference between ease, ease-in, ease-out, and linear?
These are timing functions — they control how animation speed changes over time. linear is constant speed. ease starts slow, accelerates, slows at end. ease-in starts slow, accelerates. ease-out starts fast, slows at end (good for entrance animations). ease-in-out is slow at both ends. Use ease-out for most UI animations.
How do I create a loading spinner with CSS?
Create a circle with border, give it a border-top color that differs from the others, then rotate it with @keyframes spin: 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); }. Apply with animation: spin 1s linear infinite. The differing border color creates the visual of the spinner rotating.
Should I use animation or transition for hover effects?
Use transition. Hover effects are the classic use case for transitions — a property changes on hover, transitions animate to the new value. Write transition: background-color 0.2s on the element, then background-color: #xxx on :hover. Animations are overkill for hover effects — they loop or play once, which isn't what you want when the trigger is a continuous state like hover.