How to Use CSS Animations and Transitions with JavaScript
Introduction
CSS animations are the most performant and widely used method for adding motion to web interfaces. They split into two distinct systems: transitions for smooth state changes between two values, and keyframe animations for complex, multi-step sequences.
What makes CSS animations especially powerful is how seamlessly they integrate with JavaScript. While CSS defines how something animates, JavaScript controls when, what, and why it happens. You can trigger animations by adding classes, listen for animation events to coordinate logic, dynamically change animation properties, and orchestrate complex sequences that would be impossible with CSS alone.
In this guide, you will master both CSS transitions and keyframe animations from a JavaScript developer's perspective. You will learn the full transition shorthand, every sub-property, how to listen for completion events, how to build multi-step keyframe animations, and how to control all of it programmatically.
The transition Property: Smooth State Changes
A CSS transition smoothly interpolates a property from one value to another over a specified duration. It is the simplest form of animation: you define a start state and an end state, and the browser fills in every frame in between.
How Transitions Work
Transitions require two things:
- A
transitiondeclaration on the element telling the browser which properties to animate, how long the animation should take, and optionally how it should progress - A change in the property's value, typically triggered by a pseudo-class like
:hover, a class toggle from JavaScript, or a direct style change
.box {
width: 100px;
height: 100px;
background-color: #3498db;
transition: background-color 0.3s ease;
}
.box:hover {
background-color: #e74c3c;
}
When the user hovers over .box, the background color does not snap instantly to red. Instead, it smoothly transitions over 0.3 seconds. When the mouse leaves, it transitions back just as smoothly.
The Transition Shorthand
The transition shorthand combines up to four values:
transition: property duration timing-function delay;
.element {
/* Animate opacity over 0.5s with ease-out, starting after 0.1s */
transition: opacity 0.5s ease-out 0.1s;
}
You can animate multiple properties by separating them with commas:
.card {
transition: transform 0.3s ease-out,
box-shadow 0.3s ease-out,
opacity 0.2s linear;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
opacity: 0.95;
}
Or use the keyword all to animate every property that changes:
.button {
transition: all 0.3s ease;
}
Using transition: all is convenient but can cause unexpected animations on properties you did not intend to animate, and it forces the browser to check every property for changes. Always prefer listing specific properties in production code.
Transition Sub-Properties
The transition shorthand breaks down into four individual properties, each controlling a different aspect of the animation.
transition-property
Specifies which CSS properties should be animated when their values change.
.element {
transition-property: transform, opacity, background-color;
}
Special values:
nonedisables all transitionsalltransitions every animatable property
Not all CSS properties are animatable. Properties that can be interpolated between two values (numbers, colors, lengths) generally work. Properties with discrete values (like display or visibility) traditionally do not transition smoothly, though modern browsers are adding support for some discrete transitions.
/* These properties transition smoothly */
transition-property: width, height, opacity, transform, color,
background-color, border-radius, box-shadow,
margin, padding, font-size;
/* These traditionally do NOT transition */
/* display, font-family, position, z-index (integer snaps) */
Always prefer transform and opacity for animations when possible. These two properties can be animated by the GPU compositor thread without triggering layout recalculations or repaints, making them significantly more performant than animating width, height, top, left, or margin.
transition-duration
Specifies how long the transition takes to complete. Values are in seconds (s) or milliseconds (ms).
.fast {
transition-duration: 0.15s; /* Quick micro-interaction */
}
.medium {
transition-duration: 300ms; /* Standard UI transition */
}
.slow {
transition-duration: 0.8s; /* Dramatic, deliberate motion */
}
When animating multiple properties, you can assign different durations:
.element {
transition-property: transform, opacity;
transition-duration: 0.3s, 0.15s;
/* transform takes 0.3s, opacity takes 0.15s */
}
Practical duration guidelines:
| Animation Type | Recommended Duration |
|---|---|
| Button hover, color change | 100ms to 200ms |
| Dropdown, tooltip, popup | 200ms to 300ms |
| Modal, sidebar, panel | 300ms to 500ms |
| Page-level transitions | 400ms to 800ms |
transition-timing-function
Controls the acceleration curve of the transition. This is where Bezier curves from the previous lesson come into play.
.element {
transition-timing-function: ease; /* Default */
transition-timing-function: linear; /* Constant speed */
transition-timing-function: ease-in; /* Slow start */
transition-timing-function: ease-out; /* Slow end */
transition-timing-function: ease-in-out; /* Slow both ends */
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); /* Custom */
}
There is also the steps() function, which creates a stepped transition instead of a smooth one:
.element {
/* Jump in 5 discrete steps */
transition-timing-function: steps(5, jump-start);
}
steps() syntax:
steps(number-of-steps, direction)
Where direction can be:
jump-start(orstart): the first step happens immediatelyjump-end(orend): the last step happens at the end (default)jump-both: a step at both start and endjump-none: no step at start or end
/* Typewriter effect with steps */
.typewriter {
width: 0;
overflow: hidden;
white-space: nowrap;
border-right: 2px solid black;
transition: width 2s steps(20, jump-end);
}
.typewriter.visible {
width: 20ch; /* 20 characters wide */
}
transition-delay
Specifies a waiting period before the transition begins. This is invaluable for creating staggered animations and preventing accidental triggers.
.tooltip {
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease-out,
visibility 0.2s ease-out;
transition-delay: 0.5s; /* Wait 0.5s before showing */
}
.trigger:hover .tooltip {
opacity: 1;
visibility: visible;
transition-delay: 0s; /* Show immediately on hover */
}
In this example above, the tooltip waits 0.5 seconds before disappearing (preventing flickering when the mouse briefly leaves), but appears immediately when hovered. This technique of using different delays for show and hide is extremely common in production UIs.
You can also use negative delays:
.element {
transition: transform 1s ease;
transition-delay: -0.5s;
/* Starts mid-animation, as if 0.5s had already passed */
}
Staggering Multiple Properties
Different delays on different properties create elegant staggered effects:
.card {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease-out,
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
transition-delay: 0s, 0.05s;
/* opacity starts immediately, transform starts 50ms later */
}
.card.visible {
opacity: 1;
transform: translateY(0);
}
The transitionend Event
When a CSS transition completes, the browser fires a transitionend event on the element. This is critical for coordinating JavaScript logic with CSS animations.
Basic Usage
const box = document.querySelector('.box');
box.addEventListener('transitionend', (event) => {
console.log(`Transition finished for: ${event.propertyName}`);
console.log(`Duration was: ${event.elapsedTime}s`);
});
// Trigger the transition
box.classList.add('moved');
The transitionend Event Object
The event object contains useful properties:
| Property | Description |
|---|---|
event.propertyName | The CSS property that finished transitioning (e.g., "transform", "opacity") |
event.elapsedTime | Duration of the transition in seconds (not including delay) |
event.pseudoElement | The pseudo-element if the transition ran on ::before or ::after, otherwise empty string |
Handling Multiple Property Transitions
When you transition multiple properties, transitionend fires once for each property. This can cause unexpected behavior if you are not careful.
.box {
transition: transform 0.3s ease, opacity 0.3s ease, background-color 0.3s ease;
}
const box = document.querySelector('.box');
// WRONG: This fires 3 times (once per property)
box.addEventListener('transitionend', () => {
console.log('Done!'); // Logs 3 times!
});
Solution 1: Filter by property name:
box.addEventListener('transitionend', (event) => {
// Only react to the transform transition finishing
if (event.propertyName === 'transform') {
console.log('Animation complete!');
box.remove(); // Safe to remove after animation
}
});
Solution 2: Use a flag to respond only once:
function onTransitionEnd(element) {
return new Promise((resolve) => {
function handler(event) {
if (event.target !== element) return; // Ignore bubbled events
element.removeEventListener('transitionend', handler);
resolve(event);
}
element.addEventListener('transitionend', handler);
});
}
// Usage
async function animateAndRemove(element) {
element.classList.add('fade-out');
await onTransitionEnd(element);
element.remove();
}
The transitionstart, transitionrun, and transitioncancel Events
Beyond transitionend, browsers fire additional transition events:
const element = document.querySelector('.animated');
element.addEventListener('transitionrun', (e) => {
// Fires when the transition is created (even during delay)
console.log(`Transition started running: ${e.propertyName}`);
});
element.addEventListener('transitionstart', (e) => {
// Fires when the transition actually begins animating (after delay)
console.log(`Transition visually started: ${e.propertyName}`);
});
element.addEventListener('transitionend', (e) => {
// Fires when the transition completes
console.log(`Transition ended: ${e.propertyName}`);
});
element.addEventListener('transitioncancel', (e) => {
// Fires when the transition is interrupted
console.log(`Transition cancelled: ${e.propertyName}`);
});
The transitioncancel event fires when a transition is interrupted before completing, for example, when the triggering condition is removed mid-animation, or when the transition-property is changed. If a transition is cancelled, transitionend will not fire. Always account for this in critical animation logic.
A Common Pitfall: Transition Not Firing
One of the most frustrating issues in CSS transitions is when transitionend never fires. Here are the common causes:
const box = document.querySelector('.box');
// PROBLEM: No transition happens because changes are batched in the same frame
box.style.transition = 'transform 0.3s ease';
box.style.transform = 'translateX(0)'; // Start value
box.style.transform = 'translateX(300px)'; // End value (same frame!)
// Browser sees only the final value. No transition, no event.
// SOLUTION: Force a reflow between setting start and end values
box.style.transition = 'transform 0.3s ease';
box.style.transform = 'translateX(0)';
box.offsetHeight; // Forces reflow - browser processes the start value
box.style.transform = 'translateX(300px)'; // Now this triggers a transition
Another approach using requestAnimationFrame:
box.style.transition = 'transform 0.3s ease';
box.style.transform = 'translateX(0)';
requestAnimationFrame(() => {
requestAnimationFrame(() => {
box.style.transform = 'translateX(300px)';
});
});
The double requestAnimationFrame trick is needed because a single requestAnimationFrame runs before the browser paints. The nested call ensures the first value is committed before the second is applied. This is a well-known pattern but it can feel fragile. Using offsetHeight to force a reflow is more reliable but has a performance cost.
CSS @keyframes and the animation Property
While transitions animate between two states, @keyframes animations define multi-step sequences with full control over intermediate states.
Defining Keyframes
A @keyframes rule names an animation and defines what happens at various points during the animation:
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
from is equivalent to 0% and to is equivalent to 100%. You can use percentages for multi-step animations:
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(52, 152, 219, 0.7);
}
50% {
transform: scale(1.05);
box-shadow: 0 0 0 10px rgba(52, 152, 219, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(52, 152, 219, 0);
}
}
You can also group multiple percentage stops:
@keyframes flashColors {
0%, 100% {
background-color: #3498db;
}
25% {
background-color: #e74c3c;
}
50% {
background-color: #2ecc71;
}
75% {
background-color: #f39c12;
}
}
The animation Shorthand
Apply a keyframe animation to an element with the animation property:
.element {
animation: name duration timing-function delay iteration-count direction fill-mode play-state;
}
A practical example:
.notification {
animation: fadeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
Animation Sub-Properties
Each part of the shorthand maps to an individual property:
animation-name
The name of the @keyframes rule to apply:
.element {
animation-name: fadeSlideIn;
}
You can apply multiple animations:
.element {
animation-name: fadeIn, slideUp, pulse;
}
animation-duration
How long one cycle of the animation takes:
.element {
animation-duration: 0.5s;
}
animation-timing-function
The easing curve. Works exactly like transition-timing-function:
.element {
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}
You can also set different timing functions per keyframe:
@keyframes bounce {
0% {
transform: translateY(0);
animation-timing-function: ease-in; /* Easing for 0% → 40% */
}
40% {
transform: translateY(-30px);
animation-timing-function: ease-out; /* Easing for 40% → 60% */
}
60% {
transform: translateY(-15px);
animation-timing-function: ease-in; /* Easing for 60% → 80% */
}
80% {
transform: translateY(-4px);
animation-timing-function: ease-out; /* Easing for 80% → 100% */
}
100% {
transform: translateY(0);
}
}
animation-delay
Wait time before the animation starts. Supports negative values (animation starts mid-way):
.element {
animation-delay: 0.2s;
}
.element-prestarted {
animation-delay: -0.5s; /* Starts as if 0.5s had already elapsed */
}
Negative delays are excellent for staggering a group of elements so they appear already in motion:
.bar:nth-child(1) { animation-delay: -0.0s; }
.bar:nth-child(2) { animation-delay: -0.2s; }
.bar:nth-child(3) { animation-delay: -0.4s; }
.bar:nth-child(4) { animation-delay: -0.6s; }
animation-iteration-count
How many times the animation repeats:
.element {
animation-iteration-count: 1; /* Play once (default) */
animation-iteration-count: 3; /* Play 3 times */
animation-iteration-count: infinite; /* Loop forever */
animation-iteration-count: 2.5; /* Play 2.5 times (stops mid-cycle) */
}
animation-direction
Controls whether the animation reverses on alternate cycles:
.element {
animation-direction: normal; /* 0%→100%, 0%→100%, ... */
animation-direction: reverse; /* 100%→0%, 100%→0%, ... */
animation-direction: alternate; /* 0%→100%, 100%→0%, 0%→100%, ... */
animation-direction: alternate-reverse; /* 100%→0%, 0%→100%, 100%→0%, ... */
}
alternate is perfect for creating smooth back-and-forth animations:
@keyframes float {
from { transform: translateY(0); }
to { transform: translateY(-10px); }
}
.floating-element {
animation: float 2s ease-in-out infinite alternate;
/* Floats up and down smoothly, forever */
}
animation-fill-mode
Determines the element's styles before and after the animation runs:
.element {
animation-fill-mode: none; /* Default: no styles applied outside animation */
animation-fill-mode: forwards; /* Retains the styles from the last keyframe */
animation-fill-mode: backwards; /* Applies styles from the first keyframe during delay */
animation-fill-mode: both; /* Combines forwards and backwards */
}
This is one of the most important and frequently misunderstood properties. Let us see the difference:
@keyframes slideIn {
from {
transform: translateX(-100px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* WITHOUT forwards: element snaps back to original position after animation */
.bad {
animation: slideIn 0.5s ease-out;
/* After 0.5s, the element jumps back to its pre-animation state! */
}
/* WITH forwards: element stays at the final keyframe position */
.good {
animation: slideIn 0.5s ease-out forwards;
/* After 0.5s, the element remains at translateX(0) and opacity: 1 */
}
/* backwards applies the FIRST keyframe during the delay period */
.delayed-entry {
animation: slideIn 0.5s ease-out 1s backwards;
/* During the 1s delay, the element is already at translateX(-100px), opacity: 0 */
/* Without backwards, the element would be visible at its normal position for 1s */
}
/* both = forwards + backwards */
.complete {
animation: slideIn 0.5s ease-out 1s both;
/* Hidden during delay, stays visible after animation */
}
animation-play-state
Pauses or resumes the animation:
.element {
animation-play-state: running; /* Default */
animation-play-state: paused; /* Freezes the animation at current frame */
}
This is extremely useful when combined with JavaScript:
.spinner {
animation: spin 1s linear infinite;
}
.spinner.paused {
animation-play-state: paused;
}
const spinner = document.querySelector('.spinner');
function togglePause() {
spinner.classList.toggle('paused');
}
Full Animation Example
Here is a complete example combining multiple keyframe features:
@keyframes entranceAnimation {
0% {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
60% {
opacity: 1;
transform: scale(1.02) translateY(-3px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal {
animation: entranceAnimation 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
}
Animation Events: animationstart, animationiteration, animationend
Just like transitions have transitionend, keyframe animations have their own set of JavaScript events.
The Three Animation Events
const element = document.querySelector('.animated');
element.addEventListener('animationstart', (event) => {
console.log(`Animation "${event.animationName}" started`);
console.log(`After a delay of ${event.elapsedTime}s`);
});
element.addEventListener('animationiteration', (event) => {
console.log(`Animation "${event.animationName}" completed iteration`);
console.log(`Elapsed time: ${event.elapsedTime}s`);
});
element.addEventListener('animationend', (event) => {
console.log(`Animation "${event.animationName}" finished`);
console.log(`Total elapsed time: ${event.elapsedTime}s`);
});
element.addEventListener('animationcancel', (event) => {
console.log(`Animation "${event.animationName}" was cancelled`);
});
The Animation Event Object
| Property | Description |
|---|---|
event.animationName | The name of the @keyframes rule (e.g., "fadeSlideIn") |
event.elapsedTime | Time in seconds since the animation started (excludes delay) |
event.pseudoElement | Pseudo-element string if applicable (e.g., "::before") |
animationiteration for Looping Animations
The animationiteration event fires at the end of each cycle of a repeating animation. It does not fire on the final iteration (that triggers animationend instead).
const loader = document.querySelector('.loader');
let cycles = 0;
loader.addEventListener('animationiteration', () => {
cycles++;
console.log(`Loading... cycle ${cycles}`);
// Stop after 10 cycles
if (cycles >= 10) {
loader.style.animationPlayState = 'paused';
loader.textContent = 'Done!';
}
});
Practical Example: Sequential Animations
Use animation events to chain one animation after another:
async function animateSequence(elements) {
for (const element of elements) {
element.classList.add('animate-in');
await waitForAnimation(element);
}
}
function waitForAnimation(element) {
return new Promise((resolve) => {
function handler(event) {
if (event.target !== element) return;
element.removeEventListener('animationend', handler);
resolve(event);
}
element.addEventListener('animationend', handler);
});
}
// Usage
const cards = document.querySelectorAll('.card');
animateSequence(cards);
.card {
opacity: 0;
transform: translateY(20px);
}
.card.animate-in {
animation: fadeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
Handling animationcancel
An animation can be cancelled if its animation-name is removed or changed, or if the element is hidden or removed from the DOM. When cancelled, animationend does not fire.
function waitForAnimationSafe(element) {
return new Promise((resolve, reject) => {
function onEnd(event) {
cleanup();
resolve(event);
}
function onCancel(event) {
cleanup();
reject(new Error(`Animation "${event.animationName}" was cancelled`));
}
function cleanup() {
element.removeEventListener('animationend', onEnd);
element.removeEventListener('animationcancel', onCancel);
}
element.addEventListener('animationend', onEnd);
element.addEventListener('animationcancel', onCancel);
});
}
// Usage with error handling
try {
await waitForAnimationSafe(element);
console.log('Animation completed successfully');
} catch (error) {
console.log(error.message);
}
Triggering Animations from JavaScript
This is where CSS animations and JavaScript truly merge. There are several patterns for controlling CSS animations programmatically.
Pattern 1: Toggling Classes
The most common and recommended approach. Define animations in CSS and toggle classes in JavaScript:
.fade-enter {
animation: fadeIn 0.3s ease-out forwards;
}
.fade-exit {
animation: fadeOut 0.3s ease-in forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
const modal = document.querySelector('.modal');
const openButton = document.querySelector('.open-btn');
const closeButton = document.querySelector('.close-btn');
openButton.addEventListener('click', () => {
modal.classList.remove('fade-exit');
modal.classList.add('fade-enter');
modal.hidden = false;
});
closeButton.addEventListener('click', async () => {
modal.classList.remove('fade-enter');
modal.classList.add('fade-exit');
await waitForAnimation(modal);
modal.hidden = true;
});
Pattern 2: Triggering Transitions via Style Changes
For transitions, you can change styles directly:
const element = document.querySelector('.box');
function moveTo(x, y) {
element.style.transition = 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)';
element.style.transform = `translate(${x}px, ${y}px)`;
}
document.addEventListener('click', (event) => {
moveTo(event.clientX - 50, event.clientY - 50);
});
Pattern 3: Restarting an Animation
A common challenge is replaying an animation that has already run. Simply removing and re-adding a class in the same frame does not work because the browser batches the changes:
const element = document.querySelector('.notification');
// WRONG: Does nothing - browser never sees the class being removed
function showNotificationBroken() {
element.classList.remove('animate');
element.classList.add('animate');
// The browser optimizes this into a no-op
}
// CORRECT: Force a reflow between remove and add
function showNotification() {
element.classList.remove('animate');
void element.offsetHeight; // Trigger reflow
element.classList.add('animate');
}
An alternative using the Animation API approach:
function restartAnimation(element, className) {
element.classList.remove(className);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.classList.add(className);
});
});
}
Or the cleanest modern approach using element.getAnimations():
function replayAnimation(element, className) {
// Remove and re-add class
element.classList.remove(className);
// Force reflow
void element.offsetHeight;
element.classList.add(className);
}
Pattern 4: Dynamically Creating Keyframes
You can create @keyframes rules from JavaScript using the CSSOM:
function createAnimation(name, keyframes) {
const styleSheet = document.styleSheets[0];
const keyframeRule = `@keyframes ${name} { ${keyframes} }`;
styleSheet.insertRule(keyframeRule, styleSheet.cssRules.length);
}
// Create a custom animation based on runtime data
const startColor = getUserPreferredColor();
const endColor = getTargetColor();
createAnimation('customFade', `
from { background-color: ${startColor}; }
to { background-color: ${endColor}; }
`);
document.querySelector('.box').style.animation = 'customFade 0.5s ease forwards';
Pattern 5: Controlling Play State
Pause, resume, and manipulate running animations:
const element = document.querySelector('.animated');
function pauseAnimation() {
element.style.animationPlayState = 'paused';
}
function resumeAnimation() {
element.style.animationPlayState = 'running';
}
// Pause on scroll for performance
let scrollTimeout;
window.addEventListener('scroll', () => {
pauseAnimation();
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(resumeAnimation, 150);
}, { passive: true });
Pattern 6: Staggered Entry Animations
A very common pattern is animating a list of items with staggered delays:
.list-item {
opacity: 0;
transform: translateY(20px);
}
.list-item.animate {
animation: fadeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes fadeSlideIn {
to {
opacity: 1;
transform: translateY(0);
}
}
function staggeredEntry(items, staggerMs = 50) {
items.forEach((item, index) => {
item.style.animationDelay = `${index * staggerMs}ms`;
item.classList.add('animate');
});
}
// Usage
const listItems = document.querySelectorAll('.list-item');
staggeredEntry(listItems, 60);
This creates a beautiful cascading effect where each item appears slightly after the previous one.
Pattern 7: Animation with Cleanup
A robust animation function that handles the full lifecycle:
function animateElement(element, animationClass, options = {}) {
const { onStart, onEnd, removeAfter = false } = options;
return new Promise((resolve) => {
function handleStart() {
if (onStart) onStart(element);
}
function handleEnd(event) {
if (event.target !== element) return;
element.removeEventListener('animationend', handleEnd);
element.removeEventListener('animationstart', handleStart);
element.classList.remove(animationClass);
if (onEnd) onEnd(element);
if (removeAfter) element.remove();
resolve(element);
}
element.addEventListener('animationstart', handleStart, { once: true });
element.addEventListener('animationend', handleEnd);
element.classList.add(animationClass);
});
}
// Usage: animate and remove
await animateElement(notification, 'slide-out', {
onStart: () => console.log('Exit animation started'),
removeAfter: true
});
console.log('Notification removed from DOM');
Transitions vs. Keyframe Animations: When to Use Which
Choosing between transitions and keyframe animations depends on the complexity and nature of the animation:
| Feature | Transition | Keyframe Animation |
|---|---|---|
| Trigger | Requires a state change (class, hover, style) | Runs automatically or on class add |
| States | Two states (start and end) | Multiple intermediate states |
| Looping | Not supported natively | animation-iteration-count: infinite |
| Direction | Automatically reverses when state reverts | Must set animation-direction |
| Play control | No pause/resume | animation-play-state: paused/running |
| Best for | Hover effects, toggles, simple state changes | Loading spinners, complex sequences, entrance animations |
| Performance | Slightly better (simpler for browser to optimize) | Excellent (GPU-composited when using transform/opacity) |
Use Transitions When
/* Simple hover effect */
.button {
background-color: #3498db;
transition: background-color 0.2s ease;
}
.button:hover {
background-color: #2980b9;
}
/* Toggle between two states */
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease-out;
}
.sidebar.open {
transform: translateX(0);
}
Use Keyframe Animations When
/* Loading spinner - needs to loop */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
/* Complex multi-step entrance */
@keyframes bounceIn {
0% { opacity: 0; transform: scale(0.3); }
50% { opacity: 1; transform: scale(1.05); }
70% { transform: scale(0.9); }
100% { transform: scale(1); }
}
.modal.entering {
animation: bounceIn 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* Animation that plays once on page load */
.hero-text {
animation: fadeSlideIn 0.8s ease-out 0.3s both;
}
Performance Best Practices
CSS animations can be silky smooth or janky depending on what you animate and how.
Animate Only transform and opacity
These two properties are handled by the browser's compositor and do not trigger layout or paint operations:
/* GOOD: GPU-accelerated, runs on compositor thread */
.element {
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* BAD: Triggers layout recalculation every frame */
.element-slow {
transition: width 0.3s ease, height 0.3s ease, top 0.3s ease;
}
If you need to animate size, use transform: scale() instead of width/height:
/* Instead of this */
.card:hover {
width: 320px; /* Triggers layout */
height: 240px; /* Triggers layout */
}
/* Do this */
.card:hover {
transform: scale(1.05); /* GPU-composited */
}
Use will-change Sparingly
The will-change property hints to the browser that an element will be animated, allowing it to optimize ahead of time:
.element-that-will-animate {
will-change: transform, opacity;
}
// Better: add will-change just before animating, remove after
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
});
Do not apply will-change to many elements at once. Each will-change element gets its own compositor layer, which consumes GPU memory. Use it on the specific elements that need it, right before the animation starts.
Prefer CSS Classes Over Inline Styles
// Good: browser can optimize class-based changes
element.classList.add('visible');
// Less optimal: inline styles are harder for the browser to batch
element.style.opacity = '1';
element.style.transform = 'translateY(0)';
Reduce the Number of Animated Elements
// Instead of animating 100 individual items
// Animate a container that holds them all
container.style.transition = 'transform 0.3s ease';
container.style.transform = 'translateX(-100%)';
Putting It All Together: A Complete Example
Here is a realistic example that demonstrates transitions, keyframe animations, and JavaScript event handling working together:
<!DOCTYPE html>
<html>
<head>
<style>
.toast-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1000;
}
.toast {
padding: 14px 20px;
background: #2c3e50;
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
font-family: system-ui, sans-serif;
font-size: 14px;
cursor: pointer;
/* Transition for hover effect */
transition: transform 0.2s ease, opacity 0.2s ease;
}
.toast:hover {
transform: translateX(-4px);
}
/* Entry animation */
.toast.entering {
animation: toastIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Exit animation */
.toast.exiting {
animation: toastOut 0.3s ease-in forwards;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
to {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes toastOut {
from {
opacity: 1;
transform: translateX(0) scale(1);
}
to {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
}
/* Progress bar for auto-dismiss */
.toast-progress {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: rgba(255,255,255,0.4);
border-radius: 0 0 8px 8px;
animation: shrink var(--duration) linear forwards;
}
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}
.toast {
position: relative;
overflow: hidden;
}
</style>
</head>
<body>
<button id="showToast">Show Toast</button>
<div class="toast-container" id="toastContainer"></div>
<script>
const container = document.getElementById('toastContainer');
function showToast(message, duration = 3000) {
const toast = document.createElement('div');
toast.className = 'toast entering';
toast.textContent = message;
// Add auto-dismiss progress bar
const progress = document.createElement('div');
progress.className = 'toast-progress';
progress.style.setProperty('--duration', `${duration}ms`);
toast.appendChild(progress);
container.appendChild(toast);
// Remove 'entering' class after animation completes
toast.addEventListener('animationend', function handler(event) {
if (event.animationName === 'toastIn') {
toast.classList.remove('entering');
toast.removeEventListener('animationend', handler);
}
});
// Click to dismiss early
toast.addEventListener('click', () => dismissToast(toast));
// Auto-dismiss after duration
const autoTimeout = setTimeout(() => dismissToast(toast), duration);
// Cancel auto-dismiss if manually clicked
toast.addEventListener('click', () => clearTimeout(autoTimeout), { once: true });
}
async function dismissToast(toast) {
if (toast.classList.contains('exiting')) return; // Already dismissing
toast.classList.add('exiting');
await new Promise((resolve) => {
toast.addEventListener('animationend', resolve, { once: true });
});
toast.remove();
}
// Demo
let count = 0;
document.getElementById('showToast').addEventListener('click', () => {
count++;
showToast(`Notification #${count}: Something happened!`, 4000);
});
</script>
</body>
</html>
This example uses:
- Keyframe animations for toast entry (
toastIn) and exit (toastOut) - Transitions for the hover effect on each toast
- A second keyframe animation (
shrink) for the progress bar - CSS custom properties (
--duration) set from JavaScript animationendevents to clean up classes and remove elements from the DOM- Proper guard clauses to prevent double-dismissal
Summary
CSS animations split into two systems, each with its own strengths. Transitions handle smooth changes between two states and are perfect for hover effects, toggles, and interactive feedback. Keyframe animations handle multi-step sequences and support looping, direction control, and independent play state.
From JavaScript, you control CSS animations by toggling classes, listening to events (transitionend, animationstart, animationiteration, animationend), manipulating play state, and dynamically setting animation properties. The key patterns are class toggling for triggering, forced reflow for restarting, and Promise-wrapped event listeners for sequencing.
For performance, always prefer animating transform and opacity over layout-triggering properties like width, height, or margin. Use will-change sparingly and only when needed.
The combination of CSS-defined animations with JavaScript-driven control gives you the best of both worlds: hardware-accelerated, smooth visual motion with the programmatic flexibility to respond to user interaction, data changes, and application state.