CSS Topics
🎨 CSS Design✨Lesson 16 of 16

Animations & CSS Variables

Master @keyframes multi-step animations (pulse radar, gradient flow) and CSS Custom Properties (:root variables) for robust dark mode theming.

⏱️~5 min read🟢Beginner⚡Jump to Code Editor

🎯 What You Will Master in This Capstone Lesson

1.@keyframes Anatomy: Writing multi-step timelines from scratch — from/to and percentage stops.
2.All 8 Animation Sub-Properties: Name, duration, timing, delay, iteration, direction, fill-mode — every knob explained.
3.CSS Custom Properties (Variables): Declaring :root design tokens, var() syntax, fallbacks, and scope rules.
4.Zero-JS Dark Mode: Swapping entire site palettes with 10 lines — no JavaScript needed.
5.Stagger Animation Pattern: Cascading entrance animations using animation-delay.
6.Infinite Micro-Interactions: Pulsing radar beacons, gradient text, and breathing effects — all GPU-safe.

🎬 Two Concepts That Unlock the Final Level of CSS

Before we write a single line of code, connect these two real-world ideas — they will make everything click instantly:

🎥

@keyframes = The Film Director's Storyboard

An animator drawing a superhero leap doesn't just draw the start and finish. They draw key poses: 0%: crouching on the ground → 50%: soaring through clouds → 100%: landing on the rooftop. The browser automatically generates all hundreds of in-between frames with flawless smoothness.

Think of @keyframes as your storyboard. The browser is the animation team that fills in every frame between your key poses.

💡

CSS Variables = The Diwali Master Switchboard

Imagine wiring a mansion with 1,000 Diwali fairy light bulbs. If you hardcode the wiring, changing from gold to deep blue for a festival requires replacing 1,000 individual bulbs. With a master switchboard (:root { --theme: blue; }), you flick one switch and every light transforms in a millisecond!

Think of CSS variables as your switchboard. Change one value in :root — 500 components update simultaneously.

🎞️ Anatomy of @keyframes — Writing Your Own Animation Timeline

A @keyframes block is a named timeline recipe. It has two parts: the name, and the percentage stops that define what the element looks like at each moment.

Option A: Simple Two-State (from / to)

/* Shorthand for 0% → 100% */

@keyframes fadeIn {

from { opacity: 0; }

to { opacity: 1; }

}

/* Apply it to an element: */

.hero-title {

animation: fadeIn 0.6s ease forwards;

}

Option B: Full Multi-Stop Timeline (%)

/* Full choreography — any % value */

@keyframes radarPing {

0% { transform: scale(0.5); opacity: 1; }

50% { transform: scale(1.4); opacity: 0.6; }

100% { transform: scale(2.2); opacity: 0; }

}

.radar-ring {

animation: radarPing 1.8s ease infinite;

}

Key Rules for Writing @keyframes

  • ①The name you give after @keyframes (radarPing, fadeIn) must exactly match the animation-name on the element. Case-sensitive.
  • ②You can add as many percentage stops as you like: 0%, 20%, 45%, 80%, 100%. You don't need to define all of them.
  • ③Only animate transform and opacity inside infinite loops. Properties like box-shadow, filter, or width trigger Paint on every frame, draining battery.
  • ④The @keyframes block itself does nothing — it is just a recipe. You must apply it to an element via the animation property to make it run.

The 8 Animation Sub-Properties — Every Knob Explained

Sub-PropertyExample ValueWhat It Controls
animation-nameradarPingThe exact name of the @keyframes block to run. Case-sensitive.
animation-duration1.8sHow long one complete cycle takes. Required — without this, animation won't play at all (default is 0s).
animation-timing-functionease, linear, cubic-bezier()Speed curve between each keyframe stop. Works the same as in transition.
animation-delay0.2sHow long to wait before starting. Used for stagger cascades. Negative values jump-start mid-animation.
animation-iteration-countinfinite, 3, 1.5How many times to repeat. infinite loops forever. Decimal values play partial cycles.
animation-directionnormal, reverse, alternatealternate reverses on every odd cycle — creating a seamless ping-pong effect without abrupt jumps (breathing animations).
animation-fill-modeforwards, both, backwardsforwards freezes the last frame. backwards applies the first frame during delay. both does both.
animation-play-staterunning, pausedPause/resume an animation without losing its position. Useful for pausing on hover: .card:hover .spinner { animation-play-state: paused; }

The Shorthand Order (most commonly used 6 values)

animation: radarPing 1.8s ease 0s infinite normal;

Order: name duration timing-function delay iteration-count direction. If you only need infinite looping, the 3-value shorthand works: animation: radarPing 1.8s infinite;

🎭 The Stagger Pattern — Cascading Entrance Animations

The most elegant animation pattern in modern UI: multiple elements animate in sequence using increasing animation-delay values, creating a flowing waterfall cascade effect:

/* Each card delays slightly longer than the previous */

.card:nth-child(1) { animation-delay: 0s; }

.card:nth-child(2) { animation-delay: 0.1s; }

.card:nth-child(3) { animation-delay: 0.2s; }

/* All cards share the same @keyframes animation */

.card {

animation: slideUp 0.5s ease forwards;

opacity: 0; /* Start hidden */

}

This pattern is used on almost every premium landing page — Stripe, Linear, Vercel all use staggered card entrances.

🎨 CSS Custom Properties (Variables) — The Design Token System

CSS Custom Properties (officially called Custom Properties, colloquially called CSS Variables) let you store values in named slots and reuse them everywhere. Unlike Sass/Less variables that get compiled away at build time, CSS Variables live in the actual browser DOM.

Step 1: Declare in :root (Global Scope)

/* :root = the html element — global scope */

:root {

/* Name MUST start with -- (two dashes) */

--brand-primary: #4f46e5;

--brand-secondary: #7c3aed;

--bg-surface: #ffffff;

--text-primary: #0f172a;

--spacing-lg: 2rem;

}

Step 2: Consume with var()

/* Use var(--name) anywhere in CSS */

.btn-primary {

background: var(--brand-primary);

padding: var(--spacing-lg);

}

/* With fallback — safe if variable missing: */

.card {

color: var(--text-primary, #1e293b);

/* ↑ fallback if undefined */

}

CSS Variables vs Sass/Less Variables — The Key Difference

❌ Sass Variable (pre-processor, compile-time)

// In your .scss file:

$brand: #4f46e5;

.btn { background: $brand; }

/* Compiles to plain CSS: */

.btn { background: #4f46e5; }

The variable is gone after build. JavaScript cannot read or change it. Cannot respond to @media or :hover.

✅ CSS Custom Property (live, in the browser)

/* In your .css file — stays in the DOM: */

:root { --brand: #4f46e5; }

.btn { background: var(--brand); }

/* JavaScript can read & change it at runtime: */

document.documentElement.style

.setProperty('--brand', '#10b981');

Lives in the DOM. JavaScript can read and update it in real-time. Responds to media queries, class changes, and cascade scope.

Variable Scope — Global in :root, Local in Components

Variables cascade like regular CSS — child elements inherit from ancestors. You can override a global :root variable locally within a component:

:root { --accent: #4f46e5; } /* Global: purple */

/* All buttons across the site → purple */

.btn { background: var(--accent); }

/* Local override — only inside .danger-zone */

.danger-zone { --accent: #ef4444; } /* red */

/* Buttons INSIDE .danger-zone → now red. Rest of site untouched! */

🌙 Zero-JavaScript Dark Mode Pattern

The most elegant dark mode technique: define light values in :root, then override them inside a prefers-color-scheme: dark media query. Every var() usage across the entire site updates automatically:

/* Light mode (default) */

:root {

--bg: #ffffff; --text: #0f172a; --card-bg: #f8fafc;

}

/* Dark mode — only the variable values change! */

@media (prefers-color-scheme: dark) {

:root {

--bg: #0f172a; --text: #f1f5f9; --card-bg: #1e293b;

}

}

/* All elements using var(--bg), var(--text), var(--card-bg) */

/* automatically switch between light and dark — ZERO JavaScript! */

💻 Live Animations & Custom Properties Playground

The radar beacon uses a multi-stop @keyframes timeline. The gradient heading uses -webkit-background-clip: text with an animated shifting gradient. The dark mode toggle button switches :root variable values. Try changing the keyframe percentages, colors, and duration!

Interactive Live Editor
index.html
Click & type to editHTML5
Browser Preview

Line-by-Line Code Breakdown

The key techniques inside the playground — explained in full:

@media (prefers-color-scheme: dark) { :root { --bg-surface: #1e293b; } }

The zero-JavaScript dark mode pattern. The media query fires when the user's OS is in dark mode. By redefining only the :root variables inside this query, every element that uses var(--bg-surface) anywhere in the stylesheet updates simultaneously — without touching a single element selector or writing a single line of JavaScript.

-webkit-background-clip: text; -webkit-text-fill-color: transparent;

The gradient text formula. First, a large linear-gradient is set as the background. Then -webkit-background-clip: text crops the gradient to the exact shapes of the letters. Finally, -webkit-text-fill-color: transparent makes the original text fill invisible so only the gradient background shows through the letter shapes. The @keyframes animation then shifts background-position to make the gradient appear to flow.

animation: radarPing 1.8s cubic-bezier(0, 0, 0.2, 1) infinite;

The radar beacon ring. The cubic-bezier(0, 0, 0.2, 1) timing curve starts fast and decelerates — this mimics the natural deceleration of a physical wave propagating outward from a point. The infinite keyword makes it loop forever. The element this is applied to is a ::after pseudo-element, keeping the HTML clean.

animation-fill-mode: forwards; + opacity: 0; on initial state

The entrance animation pattern. The element starts hidden (opacity: 0). The keyframe animation fades it in. animation-fill-mode: forwards freezes it in the final visible (opacity: 1) state after the animation completes, preventing the element from disappearing again. Without forwards, it would snap back to invisible the moment the animation ends.

🧠 Check Your Understanding: Animations & Variables

Q1What is the primary difference between a CSS `transition` and an `@keyframes` animation?

Q2What does `animation-fill-mode: forwards;` do when a CSS animation finishes running?

Q3In the CSS variable syntax `color: var(--accent-color, #4f46e5);`, what is `#4f46e5`?

🛠️

Practice Challenge: Build a 4-Tier Animation System

Open the live editor above and try these 4 experiments in order:

  1. Change the beacon color: Find @keyframes radarPing. Change the pulse dot color from emerald green (#10b981) to crimson red (#ef4444). Also update the rgba value to match. This creates an urgent red alert beacon!
  2. Make the heartbeat faster: Change animation-duration from 1.8s to 0.6s. Feel the urgency shift — this is exactly how live streaming "LIVE" indicator dots are built.
  3. Add a stagger: Copy the radar section HTML twice. Give the second copy animation-delay: 0.3s and the third animation-delay: 0.6s. Watch a cascading ripple of three beacons pulse in sequence!
  4. Bonus — breathing button: Add this to the CSS: create a new @keyframes breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05); } }. Apply animation: breathe 2s ease-in-out infinite alternate; to the dark mode toggle button. The button will gently breathe like a sleeping device!
💡 Click to view the full solution code

/* Red alert beacon */

.pulse-dot { background: #ef4444; }

.pulse-dot::after {

background: rgba(239, 68, 68, 0.4);

animation: radarPing 0.6s cubic-bezier(0, 0, 0.2, 1) infinite;

}

/* Breathing button */

@keyframes breathe {

0%, 100% { transform: scale(1); }

50% { transform: scale(1.05); }

}

.toggle-btn { animation: breathe 2s ease-in-out infinite alternate; }

Common Pitfalls & Interview Preparation

❌ Pitfall 1: Animating Layout Properties in Infinite Loops

Never run an infinite @keyframes animation on box-shadow, width, height, or filter. These trigger the Paint stage on every single frame — a 60fps animation triggers 60 repaints per second, continuously draining mobile battery and overheating the device. Only use transform and opacity in infinite loops.

❌ Pitfall 2: Forgetting `animation-fill-mode: forwards`

If you animate an element from opacity: 0 to opacity: 1 as an entrance effect but forget animation-fill-mode: forwards, the element will snap back to invisible the exact microsecond the animation ends. Always add forwards for one-shot entrance animations.

✅ Best Practice: `prefers-reduced-motion` for Accessibility

Always wrap your animations in a motion-safe guard. Some users have vestibular disorders where animations trigger nausea and dizziness. The professional pattern: write all animations inside @media (prefers-reduced-motion: no-preference) { ... } so they only play when the user has NOT requested reduced motion.

✅ Best Practice: Name Variables Semantically

Name CSS variables by their role, not their value. Use --color-text-primary, not --color-dark-gray. When you switch to dark mode, --color-text-primary still makes sense (it becomes light), but --color-dark-gray is confusing when its value is actually #f1f5f9 (white).

💬 Interview Q: Can CSS Custom Properties be read and updated via JavaScript?

Yes! Unlike Sass/Less preprocessor variables (which are compiled away and no longer exist in the output CSS), CSS Custom Properties live in the actual browser DOM and are fully accessible to JavaScript at runtime.

// READ a CSS variable:

getComputedStyle(document.documentElement)

.getPropertyValue('--brand-primary');

// WRITE/UPDATE a CSS variable live:

document.documentElement.style

.setProperty('--brand-primary', '#10b981');

This is how theme switchers in React, Vue, and Angular apps work — they write to CSS variables, and the entire UI updates instantly without re-rendering individual components.

💬 Interview Q: What is `animation-direction: alternate` and when is it useful?

animation-direction: alternate reverses the animation direction on every odd cycle instead of jumping back to the start position. This creates a seamless ping-pong effect.

Without alternate: The animation plays 0%→100%, then jumps back to 0% and plays 0%→100% again. This creates a jarring snap on each loop.

With alternate: The animation plays 0%→100% (forward), then smoothly reverses 100%→0% (backward), creating a continuous breathing-like loop with zero jumps.

/* Breathing effect — no jump, infinite smooth oscillation */ @keyframes breathe { from { transform: scale(1); } to { transform: scale(1.08); } } .pulse-btn { animation: breathe 2s ease-in-out infinite alternate; }
💬 Interview Q: What is the CSS Variable Scope and Cascade?

CSS Custom Properties respect the normal CSS cascade and inheritance rules. A variable defined on a parent element is inherited by all its children.

:root targets the <html> element — the highest ancestor — making variables defined there globally available to every element on the page. But any descendant can locally override the same variable for its own subtree without affecting anything outside it.

This is how theme-per-component systems work: a .dark-section overrides --bg and --text locally, so only elements inside it flip to dark colors while the rest of the page stays light.

💬 Interview Q: What is `animation-play-state` and how is it used practically?

animation-play-state: paused freezes a running animation at its current position. running resumes it from exactly where it paused — the clock picks up where it left off.

A common real-world use: pause a loading spinner when hovering over it for inspection, or pause a carousel auto-scroll when the user hovers over it (accessibility best practice):

.carousel { animation: slide 10s linear infinite; } .carousel:hover { animation-play-state: paused; } /* Also useful — pause all animations on the entire page when focused: */ .modal-open * { animation-play-state: paused; }