CSS Animations and Transitions Explained — Timing, Keyframes and Real-World Patterns
Every polished web product you've ever used — Stripe's pricing page, Linear's task cards, Apple's product reveals — has one thing in common: motion that feels intentional. That motion isn't magic. It's CSS transitions and animations doing the heavy lifting without a single line of JavaScript. When an element moves, fades, or bounces in a way that feels natural, your brain registers the UI as trustworthy and responsive. Skip the motion and everything feels like a 1997 government website.
The problem most developers run into isn't knowing the syntax — it's knowing WHY the browser behaves the way it does, WHEN to reach for a transition versus a full animation, and HOW to avoid jank that tanks your Lighthouse scores. Animating the wrong CSS property (looking at you, margin) can silently murder your 60fps budget. Animating transform and opacity instead keeps things silky because those two properties skip the expensive layout and paint steps entirely.
By the end of this article you'll know exactly how the browser renders motion, when to use transition versus @keyframes, how to sequence multi-step animations, and the exact gotchas that trip up even experienced developers — so you can ship smooth, performant UI with confidence.
CSS Transitions — Smooth State Changes Without a Single Keyframe
A CSS transition is the simplest form of animation: you tell the browser 'when THIS property changes, don't snap — glide over X seconds.' That's it. No keyframes, no JavaScript, no complexity.
The four pillars of transition are: the property you're watching, the duration of the glide, the timing function (the pace curve), and the delay before it starts. You almost always set these on the element's default state — not the hover or active state — so the transition runs in both directions (in AND out).
The timing function is where most developers stop reading, but it's where all the personality lives. ease starts fast and slows down (great for elements entering the screen). ease-in-out ramps up then back down (great for elements moving across the screen). linear is robotic and usually wrong for UI — it's only really useful for infinite loaders. cubic-bezier() lets you craft a custom curve for that satisfying 'spring' feeling.
The golden rule: only transition transform and opacity for anything that must hit 60fps. Transitioning width, height, margin, or top forces the browser to recalculate layout on every frame — that's expensive and causes jank on lower-end devices.
/* ── Base button styles ──────────────────────────────────────── */ .cta-button { display: inline-block; padding: 14px 28px; background-color: #4f46e5; /* indigo-600 */ color: #ffffff; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; /* * ALWAYS put transition on the base state, not the :hover. * This ensures the animation plays when the user LEAVES the * hover state too — not just when they enter it. * * Syntax: transition: property duration timing-function delay; * We're transitioning TWO properties, separated by a comma. * transform handles the lift; background-color handles the tint. */ transition: transform 180ms ease-out, background-color 180ms ease-out, box-shadow 180ms ease-out; } /* ── Hover state — only define the DESTINATION, not the motion ─ */ .cta-button:hover { background-color: #4338ca; /* slightly darker indigo-700 */ /* * translateY(-3px) nudges the button UP by 3px. * We use transform instead of changing margin-top or top * because transform lives on the compositor thread — * the browser can animate it without touching layout at all. */ transform: translateY(-3px); box-shadow: 0 8px 20px rgba(79, 70, 229, 0.4); } /* ── Active (click) state — snap down fast for tactile feel ───── */ .cta-button:active { transform: translateY(0px); /* returns to baseline instantly */ box-shadow: none; transition-duration: 80ms; /* faster on click = feels snappy */ }
• At rest → flat indigo button, no shadow
• On hover → lifts 3px, darker background, soft purple glow (180ms ease-out)
• On click → snaps back to flat (80ms) giving a physical 'press' sensation
• On un-hover → glides back to rest state (transition plays in reverse automatically)
CSS Keyframe Animations — When You Need a Full Choreography
Transitions only go from A to B. But what if you need A → B → C → back to A, looping forever? That's where @keyframes come in. A keyframe animation defines a named timeline with as many waypoints as you need, then you attach that timeline to any element via animation-name.
Think of @keyframes as writing a script for a play. transition is an actor who improvises between two lines. @keyframes is a full stage direction that says: at the opening curtain do THIS, at the halfway point do THAT, and at the curtain call do THIS OTHER THING.
The animation shorthand packs in eight sub-properties: name, duration, timing-function, delay, iteration-count, direction, fill-mode, and play-state. The ones that trip people up are fill-mode and direction.
fill-mode: forwards means 'hold the final keyframe state after the animation ends.' Without it, your element snaps back to its original style the moment the animation finishes — which looks like a bug but is actually the spec-correct default. animation-direction: alternate makes the animation play forward then backward on odd/even iterations, which is perfect for pulse or breathe effects without needing to mirror your keyframes manually.
/* ── Keyframe definition — the choreography script ───────────── */ /* * This creates a 'pulse-ring' effect used on notification badges, * live indicators, and 'recording' dots across most modern SaaS apps. * The ring expands outward and fades simultaneously. */ @keyframes pulse-ring { 0% { /* * Start at true size, fully visible. * scale(1) = no scaling applied yet. */ transform: scale(1); opacity: 0.75; } 70% { /* * Expand to 1.6× the original size by the 70% mark. * We go past halfway before starting to fade so the * expansion is visible before it disappears. */ transform: scale(1.6); opacity: 0; } 100% { /* Reset cleanly so the next loop starts at scale(1). */ transform: scale(1.6); opacity: 0; } } /* ── The live indicator dot ───────────────────────────────────── */ .live-indicator { position: relative; display: inline-flex; align-items: center; gap: 8px; } .live-dot { width: 12px; height: 12px; border-radius: 50%; background-color: #22c55e; /* green-500 */ } /* ── The expanding ring — a pseudo-element clone of the dot ────── */ .live-dot::before { content: ''; position: absolute; inset: 0; /* shorthand for top/right/bottom/left: 0 */ border-radius: 50%; background-color: #22c55e; /* * animation shorthand breakdown: * pulse-ring → @keyframes name * 1.5s → one cycle takes 1.5 seconds * ease-out → starts fast, eases to a stop (feels natural for expansion) * 0s → no delay on first play * infinite → keep looping forever * * We animate the ::before pseudo-element so the base * .live-dot stays visually stable at the centre. */ animation: pulse-ring 1.5s ease-out 0s infinite; } /* ── Respect users who prefer reduced motion ─────────────────── */ /* * This is NOT optional. Users with vestibular disorders * can experience nausea from looping animations. * prefers-reduced-motion is the accessibility escape hatch. */ @media (prefers-reduced-motion: reduce) { .live-dot::before { animation: none; /* kill the animation entirely for affected users */ } }
• A 12px green circle with a translucent clone behind it
• The clone expands from 12px → ~19px and fades from 75% → 0% opacity
• At 1.5s intervals it resets and repeats — the classic 'live' pulse
• On devices with prefers-reduced-motion: reduce → static green dot, no animation
Sequencing and Staggering — Making Multiple Elements Dance Together
A single animated element is fine. A whole list of cards that cascade in one by one feels premium. The technique is called staggering: each element gets the same animation, but with a progressively increasing animation-delay.
The cleanest way to stagger in plain CSS is with the --stagger-index CSS custom property (CSS variable) set inline per element. Your JavaScript or your templating engine sets the index as an inline style attribute. The CSS then reads it and calculates the delay with calc(). This keeps your animation logic in CSS where it belongs, while letting your data layer drive the count.
This pattern is used everywhere: Vercel's dashboard on load, GitHub's contribution graph, Notion's sidebar items. Once you see it, you can't unsee it. The key insight is that the delays are relative to when the animation starts, so if the container has a 200ms entrance delay, all children should account for that in their own delays.
When sequencing is more complex — a loading screen with a logo, then a tagline, then a call-to-action button appearing in order — use explicit delay values rather than a formula. Explicit is clearer and easier to tune during design review.
/* ── Keyframes for the card entrance ─────────────────────────── */ @keyframes card-enter { from { /* * Cards start 20px below their natural position AND invisible. * translateY(20px) moves DOWN (positive Y = down in CSS). * We move them UP to 0 as the animation plays. */ opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } /* ── Each card starts invisible and slides up into place ──────── */ .feature-card { opacity: 0; /* start hidden BEFORE the animation begins */ /* * animation-fill-mode: both * 'backwards' → apply the 'from' keyframe during the delay period * (prevents flash of the element before delay expires) * 'forwards' → hold the 'to' keyframe after animation completes * 'both' → does both of the above — almost always what you want * * animation-delay reads the CSS custom property --stagger-index. * Each card sets its own index inline: style="--stagger-index: 2" * so the delay = index × 80ms (0, 80ms, 160ms, 240ms...) */ animation: card-enter 400ms cubic-bezier(0.16, 1, 0.3, 1) /* ease-out-expo — fast then soft landing */ calc(var(--stagger-index, 0) * 80ms) both; } /* * HTML usage (in your template/JSX): * * <div class="feature-card" style="--stagger-index: 0">Card One</div> * <div class="feature-card" style="--stagger-index: 1">Card Two</div> * <div class="feature-card" style="--stagger-index: 2">Card Three</div> * <div class="feature-card" style="--stagger-index: 3">Card Four</div> */ /* ── Reduced motion override ──────────────────────────────────── */ @media (prefers-reduced-motion: reduce) { .feature-card { animation: none; opacity: 1; /* make sure cards are visible even with no animation */ } }
• t=0ms → Card One fades+slides up (400ms duration)
• t=80ms → Card Two begins entrance
• t=160ms → Card Three begins entrance
• t=240ms → Card Four begins entrance
• t=640ms → All cards fully visible, holding final position
• cubic-bezier(0.16,1,0.3,1) gives each card a snappy entry with a soft landing
• With prefers-reduced-motion → all 4 cards appear instantly, fully visible
| Feature / Aspect | CSS Transition | CSS @keyframes Animation |
|---|---|---|
| Trigger required? | Yes — needs a state change (hover, class toggle, focus) | No — runs on page load automatically if applied |
| Number of waypoints | 2 only: start state → end state | Unlimited: 0%, 25%, 50%, 75%, 100% etc. |
| Looping support | No — one-shot per state change | Yes — animation-iteration-count: infinite |
| Best for | Interactive feedback (hover, click, focus) | Autonomous motion (loaders, pulses, entrances) |
| Direction control | Always plays in reverse on state revert | Controlled via animation-direction: alternate / reverse |
| Performance ceiling | Same — both use compositor thread for transform+opacity | Same — both use compositor thread for transform+opacity |
| Pause/resume control | Not possible in CSS alone | Yes — animation-play-state: paused / running |
| Code complexity | Simple — 1 property, 4 sub-values | Higher — requires @keyframes block + animation shorthand |
🎯 Key Takeaways
- Put
transitionon the base state, never on:hover— this ensures the animation plays in both directions automatically. - Only animate
transformandopacityfor performance-critical motion — they're the only two properties that skip layout and paint and run entirely on the GPU compositor thread. animation-fill-mode: bothis the correct default for entrance animations —backwardshides the element during the delay,forwardsholds the final frame after completion.- Always include a
@media (prefers-reduced-motion: reduce)block — this is a functional accessibility requirement, not optional polish, and it's increasingly checked in code reviews and audits.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Putting
transitionon the hover state instead of the base state — Symptom: the hover-in animation plays smoothly, but when the cursor leaves the button it snaps back instantly with no transition — Fix: always puttransitionon the base element selector (.button), not on.button:hover. The hover state should only declare the destination values. - ✕Mistake 2: Animating
width,height,top,left, ormargin— Symptom: animation runs at 60fps on your MacBook but drops to 20fps on a mid-range Android phone, causing visible stutter — Fix: replace layout-triggering properties withtransformequivalents. Usetransform: scaleX()instead of animatingwidth. Usetransform: translate()instead of animatingtop/left. These two properties are the only ones guaranteed to stay on the GPU compositor thread. - ✕Mistake 3: Forgetting
animation-fill-mode: bothon entrance animations — Symptom: during the delay period before a staggered card animates in, it flashes visible at full opacity, then disappears, then fades back in — Fix:fill-mode: bothapplies thefromkeyframe values during the delay (keeping the element hidden/offset) and holds thetokeyframe after completion (keeping the element visible). Make this your default for any entrance animation.
Interview Questions on This Topic
- QWhat's the difference between CSS transitions and CSS animations, and how do you decide which one to reach for?
- QWhy should you prefer animating `transform` and `opacity` over properties like `width` or `margin`, and what browser mechanism makes them faster?
- QIf a CSS animation has `animation-delay: 500ms` but the element is visible before the animation starts, how do you fix the flash — and what does `animation-fill-mode: backwards` actually do?
Frequently Asked Questions
Can I use CSS transitions and animations together on the same element?
Yes, and it's common. A typical pattern is using a @keyframes animation for an entrance effect (which fires once on load) while also having a transition for interactive states like hover. They don't conflict because they target different triggers — just make sure you're not accidentally transitioning a property that's mid-animation, which can cause unpredictable interpolation.
Why does my CSS animation cause layout shift and hurt my CLS score?
You're almost certainly animating a property that affects layout — width, height, padding, margin, or top/left outside of position: fixed/absolute. These force the browser to recalculate the document layout on every frame. Switch to transform: translate() and transform: scale() as replacements, and ensure animated elements are position: absolute or fixed so they're removed from normal document flow.
What's the difference between animation-iteration-count: infinite and just making a very long animation duration?
Use infinite — it's semantic, readable, and the browser handles the loop natively without resetting state. A very long duration is a hack that breaks animation-fill-mode, makes pausing impossible, and causes a jarring restart if the user navigates away and back. infinite also composes correctly with animation-direction: alternate, which is how you get smooth back-and-forth motion without duplicating your keyframes.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.