CSS3 & Responsive Design

CSS Transitions

30 min Lesson 38 of 60

What Are CSS Transitions?

CSS transitions provide a way to control the speed and timing of property changes on an element. Without transitions, when you change a CSS property -- such as changing a button's background color on hover -- the change happens instantaneously in a single frame. With transitions, you can make that change happen gradually over a specified duration, creating smooth, polished animations that make your interface feel responsive and alive.

Transitions are one of the simplest yet most impactful tools in your CSS toolkit. They require no JavaScript, no keyframe definitions, and no animation libraries. You simply declare which properties should transition, how long the transition should take, and optionally what timing function and delay to use. The browser handles all the intermediate frames (called interpolation) automatically. This makes transitions the ideal choice for interactive state changes like hover effects, focus styles, active states, and class-based toggling.

It is important to understand that transitions are reactive -- they respond to changes in property values. They do not run on their own like CSS animations do. A transition needs a trigger: a state change caused by a pseudo-class like :hover or :focus, a class being added or removed via JavaScript, or any other mechanism that changes a property value. The transition then smoothly interpolates between the old value and the new value.

The transition-property Property

The transition-property property specifies which CSS properties should be animated when their values change. You can target a single property, multiple properties separated by commas, or use the keyword all to transition every animatable property at once.

Specifying Transition Properties

/* Transition a single property */
.button {
    background-color: #3498db;
    transition-property: background-color;
}

.button:hover {
    background-color: #2980b9;
}

/* Transition multiple specific properties */
.card {
    background-color: white;
    transform: translateY(0);
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    transition-property: transform, box-shadow, background-color;
}

.card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
    background-color: #f8f9fa;
}

/* Transition all animatable properties */
.element {
    transition-property: all;
}

/* Use "none" to disable all transitions */
.no-transitions {
    transition-property: none;
}
Tip: Avoid using transition-property: all in production code unless you specifically want every property to animate. When you use all, any property change -- including ones you did not intend to animate -- will transition. This can cause unexpected animations and, more importantly, performance issues. If the browser calculates a new layout and all transitions are active, properties like width, height, and margin will animate, triggering expensive layout recalculations on every frame. Always list the specific properties you want to transition.

The transition-duration Property

The transition-duration property defines how long the transition takes to complete, specified in seconds (s) or milliseconds (ms). A duration of 0s means the change is instant (no transition). The ideal duration depends on the type of interaction and the size of the change being animated.

Setting Transition Duration

/* Duration in seconds */
.fade {
    opacity: 1;
    transition-property: opacity;
    transition-duration: 0.3s;
}

.fade.is-hidden {
    opacity: 0;
}

/* Duration in milliseconds */
.slide {
    transform: translateX(0);
    transition-property: transform;
    transition-duration: 250ms;
}

/* Different durations for different properties */
.multi-speed {
    background-color: white;
    transform: scale(1);
    transition-property: background-color, transform;
    transition-duration: 0.5s, 0.2s;
    /* background-color takes 0.5s, transform takes 0.2s */
}
Note: Duration guidelines for good user experience: Micro-interactions (button hovers, focus rings, icon changes) should use 100 to 200 milliseconds. Small transitions (dropdown menus, tooltips, tab switches) should use 200 to 300 milliseconds. Medium transitions (modal dialogs, sidebars, page sections) should use 300 to 500 milliseconds. Anything longer than 500 milliseconds starts to feel sluggish. Anything shorter than 100 milliseconds is barely perceptible and offers little visual benefit.

The transition-timing-function Property

The transition-timing-function property controls the acceleration curve of the transition -- how the intermediate values are calculated between the start and end states. This is what makes a transition feel natural, snappy, or mechanical. CSS provides several built-in timing functions, and you can also define custom curves.

Built-in Timing Functions

CSS offers five keyword timing functions that cover the most common animation curves:

  • ease -- The default timing function. Starts slow, accelerates in the middle, and decelerates at the end. This produces the most natural-feeling motion and is appropriate for most transitions. Equivalent to cubic-bezier(0.25, 0.1, 0.25, 1.0).
  • linear -- Constant speed from start to finish with no acceleration or deceleration. Useful for properties like color changes or opacity where easing feels unnatural, and for progress bar animations. Equivalent to cubic-bezier(0, 0, 1, 1).
  • ease-in -- Starts slow and accelerates toward the end. Creates a feeling of something building up momentum. Good for elements leaving the screen. Equivalent to cubic-bezier(0.42, 0, 1.0, 1.0).
  • ease-out -- Starts fast and decelerates toward the end. Creates a feeling of something settling into place. Good for elements entering the screen. Equivalent to cubic-bezier(0, 0, 0.58, 1.0).
  • ease-in-out -- Combines ease-in and ease-out: starts slow, speeds up in the middle, and slows down at the end. Symmetric and smooth. Good for elements that move on screen. Equivalent to cubic-bezier(0.42, 0, 0.58, 1.0).

Comparing Built-in Timing Functions

.box {
    width: 100px;
    height: 100px;
    background: var(--primary);
    transition-property: transform;
    transition-duration: 0.6s;
}

.box-ease { transition-timing-function: ease; }
.box-linear { transition-timing-function: linear; }
.box-ease-in { transition-timing-function: ease-in; }
.box-ease-out { transition-timing-function: ease-out; }
.box-ease-in-out { transition-timing-function: ease-in-out; }

/* On hover, all boxes move the same distance,
   but each follows a different speed curve */
.box:hover {
    transform: translateX(200px);
}

Custom Curves with cubic-bezier()

The cubic-bezier() function gives you complete control over the transition's acceleration curve by defining a cubic Bezier curve with four parameters: cubic-bezier(x1, y1, x2, y2). The parameters represent the coordinates of two control points (P1 and P2) on the curve, where x values range from 0 to 1 (representing time) and y values can go beyond 0 and 1 (allowing overshoot effects). The curve starts at point (0, 0) and ends at point (1, 1).

Custom Cubic Bezier Timing Functions

/* Snappy entrance -- fast start, gentle settle */
.snappy {
    transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

/* Bouncy overshoot -- y2 value exceeds 1 */
.bouncy {
    transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Material Design standard easing */
.material-standard {
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

/* Material Design deceleration (entering) */
.material-decelerate {
    transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}

/* Material Design acceleration (leaving) */
.material-accelerate {
    transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
}

/* Spring-like bounce effect */
.spring {
    transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
Tip: Use online tools like cubic-bezier.com to visually design and preview custom timing functions. You can drag the control points to shape the curve and see a real-time preview of the motion. The site also lets you compare your custom curve against the built-in keywords side by side. When copying a curve you like, note that the y values above 1.0 create an overshoot effect (the property goes past the target value before settling), and values below 0.0 create an undershoot (the property goes in the opposite direction briefly before proceeding).

Stepped Timing with steps()

The steps() function creates a timing function that breaks the transition into a specific number of equal intervals, producing a discrete, frame-by-frame animation rather than a smooth one. This is useful for sprite sheet animations, typewriter effects, and countdown timers where you want distinct jumps rather than smooth interpolation.

Using steps() for Discrete Animations

/* steps(number_of_steps, direction) */

/* Typewriter effect -- move through each character position */
.typewriter {
    width: 0;
    overflow: hidden;
    white-space: nowrap;
    border-right: 2px solid var(--text-dark);
    transition-property: width;
    transition-duration: 2s;
    transition-timing-function: steps(20, end);
}

.typewriter.is-typing {
    width: 20ch; /* 20 characters wide */
}

/* Sprite sheet animation */
.sprite {
    width: 64px;
    height: 64px;
    background: url("spritesheet.png") 0 0;
    transition-property: background-position;
    transition-duration: 0.8s;
    transition-timing-function: steps(8);
}

.sprite:hover {
    background-position: -512px 0; /* 8 frames * 64px */
}

/* step-start: jump to end value immediately */
.step-start-example {
    transition-timing-function: step-start;
    /* Equivalent to steps(1, start) */
}

/* step-end: stay at start value until the end */
.step-end-example {
    transition-timing-function: step-end;
    /* Equivalent to steps(1, end) */
}

The transition-delay Property

The transition-delay property specifies how long to wait before the transition begins. It accepts values in seconds or milliseconds. A positive delay causes the transition to wait before starting. A negative delay causes the transition to start immediately but partway through the animation, as if that amount of time had already elapsed.

Using Transition Delay

/* Simple delay -- wait 0.2s before color change starts */
.button {
    background-color: var(--primary);
    transition-property: background-color;
    transition-duration: 0.3s;
    transition-delay: 0.2s;
}

/* Negative delay -- start 0.1s into the animation */
.quick-start {
    opacity: 0;
    transition-property: opacity;
    transition-duration: 0.5s;
    transition-delay: -0.1s;
    /* The element appears to start at 20% opacity instead of 0% */
}

/* Staggered delays for sequential effects */
.stagger-item:nth-child(1) { transition-delay: 0s; }
.stagger-item:nth-child(2) { transition-delay: 0.05s; }
.stagger-item:nth-child(3) { transition-delay: 0.1s; }
.stagger-item:nth-child(4) { transition-delay: 0.15s; }
.stagger-item:nth-child(5) { transition-delay: 0.2s; }

/* Different delays for different properties */
.multi-delay {
    transform: translateY(0);
    opacity: 1;
    transition-property: transform, opacity;
    transition-duration: 0.3s, 0.3s;
    transition-delay: 0s, 0.1s;
    /* transform starts immediately, opacity waits 0.1s */
}

The transition Shorthand

The transition shorthand property combines transition-property, transition-duration, transition-timing-function, and transition-delay into a single declaration. The syntax is: transition: property duration timing-function delay. You can specify multiple transitions separated by commas, each with its own property, duration, timing function, and delay.

Transition Shorthand Syntax

/* Full shorthand: property duration timing-function delay */
.element {
    transition: background-color 0.3s ease 0s;
}

/* Omitting optional values (timing-function defaults to ease, delay to 0s) */
.element {
    transition: background-color 0.3s;
}

/* Multiple transitions in one declaration */
.card {
    transition: transform 0.3s ease,
                box-shadow 0.3s ease 0.05s,
                background-color 0.2s linear;
}

/* Transitioning all properties (use with caution) */
.simple {
    transition: all 0.3s ease;
}

/* Common patterns */
.button {
    transition: background-color 0.2s ease,
                color 0.2s ease,
                border-color 0.2s ease,
                box-shadow 0.2s ease;
}

.nav-link {
    transition: color 0.15s ease,
                background-color 0.15s ease;
}

.modal {
    transition: opacity 0.3s ease,
                transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
                visibility 0.3s ease;
}
Warning: When using the shorthand with multiple transitions, be careful about the order of time values. The first time value is always interpreted as transition-duration and the second as transition-delay. Writing transition: opacity 0.1s 0.3s means a 0.1-second duration with a 0.3-second delay -- not the other way around. If you only provide one time value, it is the duration and the delay defaults to 0 seconds.

Animatable vs Non-Animatable Properties

Not every CSS property can be transitioned. A property is animatable if the browser can calculate meaningful intermediate values between the start and end states. Understanding which properties are animatable and which are not is essential for writing effective transitions.

Commonly Animatable Properties

  • Color properties: color, background-color, border-color, outline-color, text-decoration-color, box-shadow (color component)
  • Dimensional properties: width, height, min-width, max-width, min-height, max-height, padding, margin, border-width
  • Positioning: top, right, bottom, left
  • Transform: transform (translate, rotate, scale, skew)
  • Visual: opacity, filter, backdrop-filter, clip-path
  • Spacing: gap, row-gap, column-gap, letter-spacing, word-spacing, line-height
  • Box model: border-radius, box-shadow, outline-width, outline-offset
  • Typography: font-size, font-weight (between numeric values)

Non-Animatable Properties

  • display -- Cannot interpolate between block, flex, none, etc. This is the most common frustration. You cannot transition an element appearing or disappearing with display. Use opacity and visibility instead, or the newer @starting-style rule with transition-behavior: allow-discrete.
  • position -- Cannot transition between static, relative, absolute, fixed.
  • float -- Cannot transition between left, right, none.
  • overflow -- Cannot transition between visible, hidden, scroll, auto.
  • font-family -- Cannot interpolate between font families.
  • content -- Cannot transition the content property used in pseudo-elements.
  • grid-template-columns and grid-template-rows -- Have limited animation support, though modern browsers are improving this.

Workarounds for Non-Animatable Properties

/* Problem: Cannot transition display: none to display: block */

/* Solution 1: Use opacity and visibility */
.modal {
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.3s ease, visibility 0.3s ease;
}

.modal.is-open {
    opacity: 1;
    visibility: visible;
}

/* Solution 2: Use transform to move off screen */
.panel {
    transform: translateX(-100%);
    transition: transform 0.3s ease;
}

.panel.is-visible {
    transform: translateX(0);
}

/* Solution 3: Use max-height for height animation */
.accordion-content {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease;
}

.accordion-content.is-expanded {
    max-height: 500px; /* Set to a value larger than content */
}

/* Modern solution: transition-behavior (limited browser support) */
.modern-modal {
    display: none;
    opacity: 0;
    transition: opacity 0.3s ease,
                display 0.3s ease allow-discrete;
}

.modern-modal.is-open {
    display: block;
    opacity: 1;

    @starting-style {
        opacity: 0;
    }
}

The will-change Property

The will-change property tells the browser which properties you intend to animate in the near future, allowing it to set up optimizations ahead of time. When you declare will-change: transform, the browser may promote the element to its own compositing layer and pre-calculate the GPU resources needed for transform animations. This can significantly improve performance for complex animations.

Using will-change for Performance

/* Hint that transform will change on hover */
.card {
    transition: transform 0.3s ease;
}

.card:hover {
    will-change: transform;
    transform: translateY(-4px);
}

/* Better: apply will-change just before the animation starts */
.card-container:hover .card {
    will-change: transform;
}

.card-container:hover .card {
    transform: translateY(-4px);
}

/* For elements that animate frequently */
.progress-bar {
    will-change: width;
    transition: width 0.5s ease;
}

/* Do NOT do this -- applying will-change to everything wastes memory */
/* * { will-change: transform, opacity; } -- BAD */

/* Remove will-change after animation completes (via JavaScript) */
/* element.addEventListener("transitionend", () => {
    element.style.willChange = "auto";
}); */
Warning: Do not overuse will-change. Every element with will-change consumes additional GPU memory because the browser creates a new compositing layer for it. Applying will-change to dozens of elements or using will-change: transform, opacity on every element in your page will actually degrade performance by exhausting GPU memory. Use it sparingly and only on elements that genuinely need the optimization -- typically elements with complex transforms or filters that animate frequently.

Triggering Transitions

A transition needs a trigger to start. The CSS property values must actually change for the transition to fire. Here are the most common ways to trigger transitions:

Pseudo-class Triggers

Triggering with Pseudo-classes

/* :hover -- most common trigger */
.button {
    background: var(--primary);
    transition: background 0.2s ease;
}

.button:hover {
    background: var(--primary-light);
}

/* :focus and :focus-visible */
.input {
    border: 2px solid var(--border-light);
    transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

.input:focus {
    border-color: var(--primary);
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}

/* :active -- trigger while button is pressed */
.button:active {
    transform: scale(0.97);
}

/* :checked -- trigger on checkbox or radio change */
.toggle-track {
    background: #ccc;
    transition: background 0.2s ease;
}

.toggle-input:checked + .toggle-track {
    background: var(--primary);
}

/* :valid and :invalid -- form validation states */
.input {
    border: 2px solid var(--border-light);
    transition: border-color 0.3s ease;
}

.input:valid {
    border-color: #27ae60;
}

.input:invalid:not(:placeholder-shown) {
    border-color: #e74c3c;
}

Class-based Triggers (via JavaScript)

Triggering with Class Changes

/* Define the transition in the base state */
.sidebar {
    transform: translateX(-100%);
    transition: transform 0.3s ease;
}

/* The class that triggers the transition */
.sidebar.is-open {
    transform: translateX(0);
}

/* Notification slide in */
.notification {
    opacity: 0;
    transform: translateY(-20px);
    transition: opacity 0.3s ease, transform 0.3s ease;
}

.notification.is-visible {
    opacity: 1;
    transform: translateY(0);
}

/* Theme switch */
.app {
    background-color: #ffffff;
    color: #333333;
    transition: background-color 0.4s ease, color 0.4s ease;
}

.app.dark-theme {
    background-color: #1a1a2e;
    color: #e0e0e0;
}

/* JavaScript to trigger:
   document.querySelector(".sidebar").classList.toggle("is-open");
   document.querySelector(".notification").classList.add("is-visible");
   document.querySelector(".app").classList.toggle("dark-theme");
*/

Media Query Triggers

Transitions Triggered by Viewport Changes

/* Sidebar animates between collapsed and expanded */
.sidebar {
    width: 250px;
    transition: width 0.3s ease;
}

@media (max-width: 768px) {
    .sidebar {
        width: 60px;
    }
}

/* Layout transitions when resizing */
.grid-container {
    grid-template-columns: repeat(3, 1fr);
    transition: grid-template-columns 0.3s ease;
}

@media (max-width: 600px) {
    .grid-container {
        grid-template-columns: 1fr;
    }
}

Transition Events in JavaScript

CSS transitions fire JavaScript events that you can listen to for coordination, cleanup, or sequencing. There are three transition events: transitionstart, transitionrun, and transitionend. The most commonly used is transitionend, which fires when a transition completes. Each event provides information about which property transitioned, its duration, and which pseudo-element (if any) was involved.

Listening for Transition Events

/* CSS */
.modal {
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.3s ease, visibility 0.3s ease;
}

.modal.is-open {
    opacity: 1;
    visibility: visible;
}

/* JavaScript: Listen for transitionend */
/* const modal = document.querySelector(".modal");

modal.addEventListener("transitionend", (event) => {
    // event.propertyName: which CSS property finished
    // event.elapsedTime: duration in seconds
    // event.pseudoElement: empty string if not a pseudo-element

    if (event.propertyName === "opacity") {
        if (!modal.classList.contains("is-open")) {
            // Modal has fully faded out -- clean up
            modal.remove();
        }
    }
});

// transitionstart fires when the transition begins
modal.addEventListener("transitionstart", (event) => {
    console.log("Transition started:", event.propertyName);
});

// Important: transitionend fires ONCE PER PROPERTY
// If you transition transform and opacity, you get TWO events
// Filter by propertyName to avoid running cleanup twice */
Note: The transitionend event fires once for each property that finishes transitioning. If you have transition: opacity 0.3s, transform 0.3s, you will receive two transitionend events -- one for opacity and one for transform. Always check event.propertyName if you only want to react to a specific property completing. Also be aware that transitionend does not fire if the transition is interrupted (for example, if the element is removed from the DOM or the transition is overridden before completing).

Performance: Which Properties Are Cheapest to Animate?

Browser rendering involves three main stages: Layout (calculating positions and sizes), Paint (filling in pixels), and Composite (combining layers). Different CSS properties trigger different stages when they change, and this has a dramatic impact on animation performance.

The Performance Tiers

  • Composite-only (cheapest): transform and opacity are handled entirely by the GPU compositor. They do not trigger layout or paint. This means they can animate at 60 frames per second even on low-powered devices. Always prefer transform for position, size, and rotation changes, and opacity for visibility changes.
  • Paint-only (moderate): color, background-color, box-shadow, border-color, and text-shadow trigger a repaint but not a layout recalculation. These are acceptable to animate but more expensive than composite-only properties.
  • Layout-triggering (most expensive): width, height, padding, margin, top, left, right, bottom, border-width, and font-size all trigger layout recalculation. When these change, the browser must recalculate the position and size of the element and potentially every other element on the page. Avoid animating these properties whenever possible.

Performance: Good vs Bad Transitions

/* BAD: Animating layout properties */
.card-bad {
    left: 0;
    top: 0;
    width: 200px;
    transition: left 0.3s, top 0.3s, width 0.3s;
}

.card-bad:hover {
    left: 20px;   /* Triggers layout */
    top: -10px;   /* Triggers layout */
    width: 220px; /* Triggers layout */
}

/* GOOD: Using transforms instead */
.card-good {
    transition: transform 0.3s ease;
}

.card-good:hover {
    transform: translate(20px, -10px) scale(1.1);
    /* Only compositing -- no layout or paint triggered */
}

/* BAD: Animating box-shadow (triggers paint) */
.card-shadow-bad {
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    transition: box-shadow 0.3s ease;
}

.card-shadow-bad:hover {
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}

/* GOOD: Using pseudo-element for shadow animation */
.card-shadow-good {
    position: relative;
}

.card-shadow-good::after {
    content: "";
    position: absolute;
    inset: 0;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
    opacity: 0;
    transition: opacity 0.3s ease;
    border-radius: inherit;
    z-index: -1;
}

.card-shadow-good:hover::after {
    opacity: 1;
    /* Only opacity changes (composite) -- shadow itself does not animate */
}

/* BAD: Animating height for accordion */
.accordion-bad {
    height: 0;
    overflow: hidden;
    transition: height 0.3s ease;
}

/* BETTER: Animating max-height (still triggers layout but simpler) */
.accordion-better {
    max-height: 0;
    overflow: hidden;
    transition: max-height 0.3s ease;
}

.accordion-better.is-open {
    max-height: 500px;
}

Practical Examples

Now let us apply everything we have learned to real-world interface components. These examples demonstrate professional-quality transitions for common UI elements.

Interactive Button with Multiple Transitions

Polished Button Transitions

.btn {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.75rem 1.5rem;
    background: var(--primary);
    color: white;
    border: 2px solid var(--primary);
    border-radius: 8px;
    font-weight: 600;
    cursor: pointer;
    position: relative;
    overflow: hidden;
    transition: background-color 0.2s ease,
                color 0.2s ease,
                border-color 0.2s ease,
                transform 0.15s ease,
                box-shadow 0.2s ease;
}

/* Hover: lighten and lift */
.btn:hover {
    background: var(--primary-light);
    border-color: var(--primary-light);
    transform: translateY(-1px);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

/* Active: press down */
.btn:active {
    transform: translateY(0);
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}

/* Focus: ring for accessibility */
.btn:focus-visible {
    outline: none;
    box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.4);
}

/* Disabled: muted appearance */
.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    transform: none;
    box-shadow: none;
}

/* Outline variant */
.btn-outline {
    background: transparent;
    color: var(--primary);
}

.btn-outline:hover {
    background: var(--primary);
    color: white;
}

Dropdown Menu with Transitions

Animated Dropdown Menu

.dropdown {
    position: relative;
}

.dropdown-trigger {
    padding: 0.5rem 1rem;
    background: none;
    border: 1px solid var(--border-light);
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    transition: border-color 0.2s ease;
}

.dropdown-trigger:hover {
    border-color: var(--primary);
}

/* Arrow rotation */
.dropdown-arrow {
    transition: transform 0.2s ease;
}

.dropdown.is-open .dropdown-arrow {
    transform: rotate(180deg);
}

/* Dropdown panel */
.dropdown-menu {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    min-width: 200px;
    background: var(--bg-white);
    border: 1px solid var(--border-light);
    border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
    padding: 0.5rem 0;

    /* Transition properties */
    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px);
    transition: opacity 0.2s ease,
                visibility 0.2s ease,
                transform 0.2s ease;
}

.dropdown.is-open .dropdown-menu {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
}

/* Menu items */
.dropdown-item {
    display: block;
    width: 100%;
    padding: 0.5rem 1rem;
    text-align: left;
    background: none;
    border: none;
    cursor: pointer;
    color: var(--text-dark);
    transition: background-color 0.15s ease;
}

.dropdown-item:hover {
    background: var(--bg-light);
}

.dropdown-item:focus-visible {
    background: var(--bg-light);
    outline: 2px solid var(--primary);
    outline-offset: -2px;
}

Accordion with Smooth Expand/Collapse

Accordion with CSS Transitions

.accordion-item {
    border: 1px solid var(--border-light);
    border-radius: 8px;
    margin-bottom: 0.5rem;
    overflow: hidden;
}

.accordion-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    padding: 1rem 1.25rem;
    background: var(--bg-white);
    border: none;
    cursor: pointer;
    font-weight: 600;
    text-align: left;
    transition: background-color 0.2s ease;
}

.accordion-header:hover {
    background: var(--bg-light);
}

/* Plus/minus icon */
.accordion-icon {
    width: 20px;
    height: 20px;
    position: relative;
    flex-shrink: 0;
}

.accordion-icon::before,
.accordion-icon::after {
    content: "";
    position: absolute;
    background: var(--text-dark);
    transition: transform 0.3s ease;
}

.accordion-icon::before {
    top: 50%;
    left: 0;
    width: 100%;
    height: 2px;
    transform: translateY(-50%);
}

.accordion-icon::after {
    top: 0;
    left: 50%;
    width: 2px;
    height: 100%;
    transform: translateX(-50%);
}

/* Rotate the vertical line to 0 when open (becomes just horizontal) */
.accordion-item.is-open .accordion-icon::after {
    transform: translateX(-50%) rotate(90deg);
}

/* Content panel */
.accordion-body {
    display: grid;
    grid-template-rows: 0fr;
    transition: grid-template-rows 0.3s ease;
}

.accordion-item.is-open .accordion-body {
    grid-template-rows: 1fr;
}

.accordion-body-inner {
    overflow: hidden;
}

.accordion-body-inner p {
    padding: 0 1.25rem 1rem;
    margin: 0;
}
Tip: The grid-template-rows: 0fr to 1fr technique is one of the best ways to animate height from zero to auto in CSS. Unlike the max-height hack (which requires guessing a maximum value and often has mismatched timing), the grid approach smoothly transitions to the content's actual height. Set overflow: hidden on the inner element and use grid-template-rows on the wrapper. The inner content defines the natural height, and the grid transition interpolates between 0 and that natural height.

Card Hover Effects

Card Component with Multiple Transition Effects

.card {
    background: var(--bg-white);
    border-radius: 12px;
    overflow: hidden;
    transition: transform 0.3s ease,
                box-shadow 0.3s ease;
}

.card:hover {
    transform: translateY(-4px);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}

/* Image zoom on hover */
.card-image {
    overflow: hidden;
}

.card-image img {
    width: 100%;
    display: block;
    transition: transform 0.4s ease;
}

.card:hover .card-image img {
    transform: scale(1.05);
}

/* Link arrow slides on hover */
.card-link {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    color: var(--primary);
    text-decoration: none;
    font-weight: 600;
}

.card-link .arrow {
    transition: transform 0.2s ease;
}

.card-link:hover .arrow {
    transform: translateX(4px);
}

/* Overlay that appears on hover */
.card-overlay {
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, 0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transition: opacity 0.3s ease;
}

.card:hover .card-overlay {
    opacity: 1;
}

.card-overlay .overlay-text {
    color: white;
    transform: translateY(10px);
    transition: transform 0.3s ease 0.1s;
}

.card:hover .card-overlay .overlay-text {
    transform: translateY(0);
}

Reverse Transitions: Different Timing for Open and Close

Sometimes you want a different transition when a state reverts. For example, a dropdown might open quickly but close more slowly, or vice versa. You can achieve this by placing different transition declarations in the base state (for the "closing" transition) and the active state (for the "opening" transition).

Asymmetric Transitions

/* The transition on the base state controls the REVERSE (closing) */
.tooltip {
    opacity: 0;
    transform: translateY(8px);
    /* Closing: slow fade out */
    transition: opacity 0.4s ease, transform 0.4s ease;
}

/* The transition on the active state controls the FORWARD (opening) */
.trigger:hover .tooltip {
    opacity: 1;
    transform: translateY(0);
    /* Opening: quick snap in */
    transition: opacity 0.15s ease, transform 0.15s ease;
}

/* Another pattern: fast open, slow close with delay */
.menu {
    opacity: 0;
    visibility: hidden;
    /* Closing: slow with 0.1s delay (wait before closing) */
    transition: opacity 0.3s ease 0.1s,
                visibility 0.3s ease 0.1s;
}

.menu-parent:hover .menu {
    opacity: 1;
    visibility: visible;
    /* Opening: instant, no delay */
    transition: opacity 0.2s ease 0s,
                visibility 0.2s ease 0s;
}

Practice Exercise

Build an interactive component showcase that demonstrates CSS transitions. Create a page with the following elements, each using transitions: (1) A button with hover, focus, and active states that transitions background color, transform (slight lift on hover, press on active), and box-shadow. (2) A toggle switch styled with CSS that transitions its track color and thumb position when a hidden checkbox is checked. (3) A card component where hovering causes the card to lift with a shadow, the image inside to zoom slightly, and an overlay with text to fade in. (4) An accordion with three sections where clicking a header smoothly expands or collapses the content using the grid-template-rows technique. (5) A notification bar that slides down from the top of the page when a button adds an is-visible class. Use appropriate durations (100-300ms for interactive elements), the correct timing functions (ease-out for entrances, ease-in for exits), and ensure all animations use only transform and opacity where possible for best performance. Test with the browser dev tools Performance panel to verify no layout thrashing occurs during transitions.