CSS Transition Recipes
14 CSS transition patterns. Each includes CSS, HTML hooks, and JS orchestration where needed. All read from a shared :root custom properties block.
Contents
- Custom properties
- Container morph
- Card resize
- Panel reveal
- Notification badge
- Icon swap
- Menu dropdown
- Modal dialog
- Text state swap
- Page side-by-side slides
- Number pop-in
- Odometer digit roll
- Avatar group hover
- Success celebration
- Error state shake
Custom properties
Add this :root block once to your global stylesheet; every recipe reads these names.
:root {
/* Container morph */
--morph-open-dur: 580ms;
--morph-close-dur: 300ms;
--morph-open-ease: linear(0, 0.45, 0.78, 1, 1.17, 1.21, 1.18, 1.12, 1.05, 1.02, 1);
--morph-close-ease: cubic-bezier(0.32, 0.72, 0, 1);
--morph-content-dur: 140ms;
--morph-content-blur: 3px;
/* Card resize */
--resize-dur: 300ms;
--resize-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Odometer digit roll */
--odo-dur: 260ms;
--odo-ease: cubic-bezier(0.22, 1, 0.36, 1);
--odo-dir: 1; /* 1 = value increased, -1 = decreased */
/* Number pop-in */
--digit-dur: 500ms;
--digit-dist: 12px;
--digit-stagger: 70ms;
--digit-blur: 6px;
--digit-ease: cubic-bezier(0.22, 1, 0.36, 1);
--digit-dir-x: 0;
--digit-dir-y: 1;
/* Notification badge */
--badge-slide-dur: 260ms;
--badge-pop-dur: 500ms;
--badge-blur: 2px;
--badge-offset-x: -8px;
--badge-offset-y: 12px;
--badge-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Text state swap */
--text-swap-dur: 150ms;
--text-swap-y: 4px;
--text-swap-blur: 2px;
--text-swap-ease: ease-in-out;
/* Menu dropdown */
--dropdown-open-dur: 250ms;
--dropdown-close-dur: 150ms;
--dropdown-pre-scale: 0.96;
--dropdown-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Modal dialog */
--modal-open-dur: 250ms;
--modal-close-dur: 150ms;
--modal-scale: 0.96;
--modal-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Panel reveal */
--panel-open-dur: 400ms;
--panel-close-dur: 350ms;
--panel-translate-y: 12px;
--panel-blur: 4px;
--panel-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Page side-by-side */
--page-dur: 200ms;
--page-dist: 8px;
--page-blur: 3px;
--page-stagger: 60ms;
--page-exit-enabled: 1;
--page-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Icon swap */
--icon-swap-dur: 200ms;
--icon-swap-blur: 2px;
--icon-swap-start-scale: 0.25;
--icon-swap-ease: ease-in-out;
/* Success celebration */
--success-opacity-dur: 550ms;
--success-rotate-dur: 550ms;
--success-bob-dur: 550ms;
--success-blur-dur: 400ms;
--success-path-dur: 550ms;
--success-path-delay: 80ms;
--success-rotate-from: 80deg;
--success-rotate-to: 0deg;
--success-bob-y: 40px;
--success-blur-from: 10px;
--success-ease: cubic-bezier(0.22, 1, 0.36, 1);
--success-bob-ease: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Avatar group hover */
--avatar-lift: -4px;
--avatar-dur: 320ms;
--avatar-scale: 1.05;
--avatar-falloff: 0.45;
--avatar-ease-in: cubic-bezier(0.22, 1, 0.36, 1);
--avatar-ease-out: cubic-bezier(0.34, 3.85, 0.64, 1);
/* Error state shake */
--shake-dist: 4px;
--shake-overshoot: 2px;
--shake-dur-1: 80ms;
--shake-dur-2: 80ms;
--shake-dur-3: 60ms;
--shake-ease: cubic-bezier(0.36, 0.07, 0.19, 0.97);
--shake-revert-dur: 200ms;
--shake-hold: 1200ms;
}Container morph
The trigger becomes the surface. A button, chip, or pill grows in place into the search field, form, menu, or confirmation it summons, keeping one continuous background and border-radius throughout. No new element appears, so there is nothing for the eye to re-find.
Use this over Menu dropdown or Modal dialog whenever the trigger and the surface can share a shape. Use Card resize instead when the container already exists and only its dimensions change.
Three things happen at once, and the order matters:
| Phase | What | Timing |
|---|---|---|
| 1 | Old content fades and blurs out | 0 to --morph-content-dur |
| 2 | Container tweens to the new box | full --morph-open-dur |
| 3 | New content fades and blurs in | starts at --morph-content-dur |
Measure the target box before animating: a plain width: auto has nothing to interpolate towards. Where interpolate-size: allow-keywords is supported you can transition to auto and drop the measure step, so check support for your targets before choosing.
<div class="t-morph" data-open="false">
<div class="t-morph-face" data-face="closed"><button>Notify me</button></div>
<div class="t-morph-face" data-face="open">
<input placeholder="Email" /><button>Notify me</button>
</div>
</div>.t-morph {
position: relative;
overflow: hidden;
border-radius: 999px;
transition: width var(--morph-close-dur) var(--morph-close-ease),
height var(--morph-close-dur) var(--morph-close-ease);
will-change: width, height;
}
.t-morph[data-open="true"] {
transition-duration: var(--morph-open-dur);
transition-timing-function: var(--morph-open-ease);
}
/* Faces stack, so the container never sees both in flow. */
.t-morph-face {
transition: opacity var(--morph-content-dur) ease,
filter var(--morph-content-dur) ease;
}
.t-morph-face[data-face="open"] { position: absolute; inset: 0; }
.t-morph[data-open="false"] [data-face="open"],
.t-morph[data-open="true"] [data-face="closed"] {
opacity: 0;
filter: blur(var(--morph-content-blur));
pointer-events: none;
}
/* Incoming content waits for the box to be most of the way there. */
.t-morph[data-open="true"] [data-face="open"],
.t-morph[data-open="false"] [data-face="closed"] {
transition-delay: var(--morph-content-dur);
}JS, measure then toggle:
function morph(el, open) {
const face = el.querySelector(`[data-face="${open ? "open" : "closed"}"]`);
// Measure the target face off-flow, at its natural size.
const prev = face.style.cssText;
Object.assign(face.style, { position: "absolute", visibility: "hidden", width: "max-content" });
const { width, height } = face.getBoundingClientRect();
face.style.cssText = prev;
el.style.width = `${width}px`;
el.style.height = `${height}px`;
el.dataset.open = String(open);
}Measured reference. Tracking a real implementation frame by frame at 60fps: the open reached 121% of its travel at 284ms (a container 9.8% wider than its resting width), then settled over a further 300ms. The close reached a ~2% undershoot in 185ms and was visually at rest by 300ms. Expansion was symmetric about the trigger's centre, not anchored to an edge.
--morph-open-ease is that overshoot transcribed as a linear() curve, so --morph-open-dur covers the whole settle even though the morph reads as finished around 300ms. The equivalent spring is { stiffness: 155, damping: 11, mass: 1 } (damping ratio 0.44); the close fits { stiffness: 620, damping: 36 }, near enough to critically damped that the bezier above is indistinguishable. Prefer the spring form when the morph must survive interruption. spring-animations.md § Asymmetric spring character covers why only the open bounces.
Card resize
Tween a container's width or height when its layout state changes (compact/expanded card, collapsing panel, list row toggling detail). CSS only, no JS.
<div class="t-resize">Content</div>.t-resize {
transition: width var(--resize-dur) var(--resize-ease),
height var(--resize-dur) var(--resize-ease);
will-change: width, height;
overflow: hidden;
}Toggle dimensions with a state class or inline style; the transition handles the tween.
Panel reveal
Slide a panel into an existing container with cross-blur. CSS only, toggle data-open.
See also: component-patterns.md § Drawers and panels for percentage-based drawer slides.
<div class="t-panel" data-open="false">Panel content</div>.t-panel {
opacity: 0;
transform: translateY(var(--panel-translate-y));
filter: blur(var(--panel-blur));
transition: opacity var(--panel-open-dur) var(--panel-ease),
transform var(--panel-open-dur) var(--panel-ease),
filter var(--panel-open-dur) var(--panel-ease);
}
.t-panel[data-open="true"] {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
.t-panel[data-open="false"] {
transition-duration: var(--panel-close-dur);
}Notification badge
Slide a small badge onto a trigger (button, icon) and pop the dot; the trigger stays put. CSS only, toggle data-open.
<button style="position: relative">
Inbox
<span class="t-badge" data-open="false">
<span class="t-badge-dot"></span>
</span>
</button>.t-badge {
position: absolute;
opacity: 0;
transform: translate(var(--badge-offset-x), var(--badge-offset-y));
filter: blur(var(--badge-blur));
transition: opacity var(--badge-slide-dur) var(--badge-ease),
transform var(--badge-slide-dur) var(--badge-ease),
filter var(--badge-slide-dur) var(--badge-ease);
}
.t-badge[data-open="true"] {
opacity: 1;
transform: translate(0, 0);
filter: blur(0);
}
.t-badge-dot {
display: block;
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
transform: scale(0);
transition: transform var(--badge-pop-dur) var(--badge-ease);
}
.t-badge[data-open="true"] .t-badge-dot {
transform: scale(1);
transition-delay: calc(var(--badge-slide-dur) * 0.5);
}Icon swap
Cross-fade two icons in one slot (hamburger/close, play/pause). CSS grid stacks both. Toggle data-state.
See also: contextual-animations.md § Contextual icon swaps for the Motion/AnimatePresence approach.
<span class="t-icon-swap" data-state="a">
<span class="t-icon" data-icon="a">☰</span>
<span class="t-icon" data-icon="b">✕</span>
</span>.t-icon-swap {
display: inline-grid;
}
.t-icon {
grid-area: 1 / 1;
opacity: 0;
transform: scale(var(--icon-swap-start-scale));
filter: blur(var(--icon-swap-blur));
transition: opacity var(--icon-swap-dur) var(--icon-swap-ease),
transform var(--icon-swap-dur) var(--icon-swap-ease),
filter var(--icon-swap-dur) var(--icon-swap-ease);
}
.t-icon-swap[data-state="a"] [data-icon="a"],
.t-icon-swap[data-state="b"] [data-icon="b"] {
opacity: 1;
transform: scale(1);
filter: blur(0);
}Menu dropdown
Origin-aware dropdown with open/close animations. JS handles close-state cleanup.
See also: component-patterns.md § Popovers and dropdowns for library transform-origin and scale patterns.
<div class="t-dropdown" data-origin="top-left">
Menu content
</div>.t-dropdown {
opacity: 0;
transform: scale(var(--dropdown-pre-scale));
transition: opacity var(--dropdown-open-dur) var(--dropdown-ease),
transform var(--dropdown-open-dur) var(--dropdown-ease);
}
.t-dropdown.is-open {
opacity: 1;
transform: scale(1);
}
.t-dropdown.is-closing {
opacity: 0;
transform: scale(0.99);
transition-duration: var(--dropdown-close-dur);
}
.t-dropdown[data-origin="top-left"] { transform-origin: top left; }
.t-dropdown[data-origin="top-center"] { transform-origin: top center; }
.t-dropdown[data-origin="top-right"] { transform-origin: top right; }
.t-dropdown[data-origin="bottom-left"] { transform-origin: bottom left; }
.t-dropdown[data-origin="bottom-center"]{ transform-origin: bottom center; }
.t-dropdown[data-origin="bottom-right"] { transform-origin: bottom right; }JS, close with cleanup:
function closeDropdown(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--dropdown-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}Modal dialog
Scale-up modal with softer scale-down on close. Class-based state.
See also: component-patterns.md § Modals and dialogs for @starting-style entry pattern.
<div class="t-modal" role="dialog">Modal content</div>.t-modal {
opacity: 0;
transform: scale(var(--modal-scale));
transform-origin: center;
transition: opacity var(--modal-open-dur) var(--modal-ease),
transform var(--modal-open-dur) var(--modal-ease);
}
.t-modal.is-open {
opacity: 1;
transform: scale(1);
}
.t-modal.is-closing {
opacity: 0;
transform: scale(var(--modal-scale));
transition-duration: var(--modal-close-dur);
}JS, close with cleanup:
function closeModal(el) {
el.classList.add("is-closing");
el.classList.remove("is-open");
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--modal-close-dur"));
setTimeout(() => el.classList.remove("is-closing"), dur);
}Text state swap
Swap text in place with a blurred vertical transition ("Processing..." → "Done"). JS coordinates the three phases.
<span class="t-text-swap">Processing...</span>.t-text-swap {
display: inline-block;
transition: opacity var(--text-swap-dur) var(--text-swap-ease),
transform var(--text-swap-dur) var(--text-swap-ease),
filter var(--text-swap-dur) var(--text-swap-ease);
}
.t-text-swap.is-exit {
opacity: 0;
transform: translateY(calc(-1 * var(--text-swap-y)));
filter: blur(var(--text-swap-blur));
}
.t-text-swap.is-enter-start {
opacity: 0;
transform: translateY(var(--text-swap-y));
filter: blur(var(--text-swap-blur));
}JS, three-phase orchestration:
function swapText(el, newText) {
const dur = parseFloat(getComputedStyle(el).getPropertyValue("--text-swap-dur"));
el.classList.add("is-exit");
setTimeout(() => {
el.textContent = newText;
el.classList.remove("is-exit");
el.classList.add("is-enter-start");
void el.offsetWidth; // force reflow
el.classList.remove("is-enter-start");
}, dur);
}Page side-by-side slides
Slide between two adjacent pages (list/detail, wizard steps). Page 1 exits left, page 2 enters right.
See also: component-patterns.md § Step form navigation for the Motion/AnimatePresence approach with direction variants.
<div class="t-page-slide" data-page="1">
<section data-page-id="1">Page 1</section>
<section data-page-id="2">Page 2</section>
</div>.t-page-slide {
display: grid;
overflow: hidden;
}
.t-page-slide > * {
grid-area: 1 / 1;
transition: opacity var(--page-dur) var(--page-ease),
transform var(--page-dur) var(--page-ease),
filter var(--page-dur) var(--page-ease);
}
/* Page 1 active */
.t-page-slide[data-page="1"] [data-page-id="1"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="1"] [data-page-id="2"] {
opacity: 0;
transform: translateX(var(--page-dist));
filter: blur(var(--page-blur));
}
/* Page 2 active */
.t-page-slide[data-page="2"] [data-page-id="2"] {
opacity: 1; transform: translateX(0); filter: blur(0);
}
.t-page-slide[data-page="2"] [data-page-id="1"] {
opacity: 0;
transform: translateX(calc(-1 * var(--page-dist)));
filter: blur(var(--page-blur));
}JS, switch page:
slider.setAttribute("data-page", String(n));Set --page-exit-enabled: 0 for fade-only, no slide (useful on initial load).
Number pop-in
Re-enter digits with directional blur on number update (counters, prices, balances). Each digit animates individually with optional stagger.
<span class="t-digits">
<span class="t-digit">1</span>
<span class="t-digit">2</span>
<span class="t-digit" data-stagger="1">3</span>
<span class="t-digit" data-stagger="2">4</span>
</span>.t-digits {
display: inline-flex;
}
@keyframes digit-enter {
from {
opacity: 0;
transform: translate(
calc(var(--digit-dir-x) * var(--digit-dist)),
calc(var(--digit-dir-y) * var(--digit-dist))
);
filter: blur(var(--digit-blur));
}
}
.t-digit {
display: inline-block;
animation: digit-enter var(--digit-dur) var(--digit-ease) both;
}
.t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); }
.t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); }JS, replay on update:
function updateDigits(container, newValue) {
container.classList.remove("is-animating");
container.innerHTML = String(newValue)
.split("")
.map((d, i, arr) => {
const stagger = i >= arr.length - 2 ? ` data-stagger="${arr.length - 1 - i}"` : "";
return `<span class="t-digit"${stagger}>${d}</span>`;
})
.join("");
void container.offsetWidth; // force reflow
container.classList.add("is-animating");
}Odometer digit roll
Roll each changed digit vertically, in the direction the value moved: up for an increase, down for a decrease. Use this over Number pop-in when the number is being driven by the user (steppers, sliders, scrubbers, quantity controls), where direction is the feedback. Keep pop-in for values that arrive on their own, where there is no direction to convey.
Two rules keep it readable. Only re-render the digits that actually changed, or a 199 to 200 tick rolls all three and reads as noise. And set font-variant-numeric: tabular-nums, or the row re-flows on every tick and the roll turns into a jitter.
<span class="t-odo" style="--odo-dir: 1">
<span class="t-odo-slot"><span class="t-odo-digit">4</span></span>
<span class="t-odo-slot"><span class="t-odo-digit" data-rolling>1</span></span>
</span>.t-odo {
display: inline-flex;
font-variant-numeric: tabular-nums;
}
.t-odo-slot {
display: inline-block;
overflow: hidden; /* the window the digit rolls through */
height: 1em;
line-height: 1em;
}
@keyframes odo-roll {
from {
transform: translateY(calc(var(--odo-dir) * 1em));
opacity: 0;
}
}
.t-odo-digit[data-rolling] {
display: block;
animation: odo-roll var(--odo-dur) var(--odo-ease) both;
}JS, roll only what changed:
function setOdometer(el, next, prev) {
el.style.setProperty("--odo-dir", next > prev ? 1 : -1);
const a = String(prev).padStart(String(next).length, " ");
const b = String(next);
el.innerHTML = [...b]
.map((d, i) => {
const rolling = d !== a[i] ? " data-rolling" : "";
return `<span class="t-odo-slot"><span class="t-odo-digit"${rolling}>${d}</span></span>`;
})
.join("");
}The outgoing digit is dropped rather than animated out. At 260ms with the slot clipping, the eye reads the incoming digit as having pushed the old one away; animating both doubles the work for no visible gain.
Avatar group hover
Distance-falloff lift on a horizontal stack. Hovered item lifts and scales; neighbors lift less with distance. Bouncy spring on leave.
<div class="t-avatar-group">
<div class="t-avatar">A</div>
<div class="t-avatar">B</div>
<div class="t-avatar">C</div>
</div>.t-avatar-group {
display: flex;
gap: 4px;
}
.t-avatar {
transition: transform var(--avatar-dur) var(--avatar-ease-in);
}JS, distance-based lift:
Set transition-timing-function inline before writing CSS variables. The browser applies whatever timing function is current when the property changes, giving smooth ease-in on hover and bouncy ease-out on return without separate declarations.
const group = document.querySelector(".t-avatar-group");
const items = [...group.querySelectorAll(".t-avatar")];
const lift = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-lift"));
const scale = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-scale"));
const falloff = parseFloat(getComputedStyle(group).getPropertyValue("--avatar-falloff"));
group.addEventListener("mouseenter", (e) => {
const target = e.target.closest(".t-avatar");
if (!target) return;
const idx = items.indexOf(target);
items.forEach((item, i) => {
const dist = Math.abs(i - idx);
item.style.transitionTimingFunction = "var(--avatar-ease-in)";
if (dist === 0) {
item.style.transform = `translateY(${lift}px) scale(${scale})`;
} else {
const y = lift * Math.pow(falloff, dist);
item.style.transform = `translateY(${y}px)`;
}
});
}, true);
group.addEventListener("mouseleave", () => {
items.forEach((item) => {
item.style.transitionTimingFunction = "var(--avatar-ease-out)";
item.style.transform = "";
});
});Success celebration
Multi-layered success: fade, rotation, blur reduction, Y-bob with overshoot, optional SVG stroke draw. Toggle data-state to "in".
<div class="t-success" data-state="out">
<svg><path class="t-success-path" d="..." /></svg>
</div>.t-success {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
@keyframes success-in {
0% {
opacity: 0;
transform: rotate(var(--success-rotate-from)) translateY(var(--success-bob-y));
filter: blur(var(--success-blur-from));
}
100% {
opacity: 1;
transform: rotate(var(--success-rotate-to)) translateY(0);
filter: blur(0);
}
}
.t-success[data-state="in"] {
animation: success-in var(--success-opacity-dur) var(--success-ease) forwards;
}
.t-success-path {
stroke-dashoffset: var(--path-length);
stroke-dasharray: var(--path-length);
transition: stroke-dashoffset var(--success-path-dur) var(--success-ease);
transition-delay: var(--success-path-delay);
}
.t-success[data-state="in"] .t-success-path {
stroke-dashoffset: 0;
}JS, set path length and replay:
Never hardcode stroke-dasharray. Use getTotalLength() to measure the actual path.
function playSuccess(el) {
const path = el.querySelector(".t-success-path");
if (path) {
const len = path.getTotalLength();
el.style.setProperty("--path-length", len);
}
el.setAttribute("data-state", "out");
void el.offsetWidth; // force reflow to restart keyframes
el.setAttribute("data-state", "in");
}Error state shake
Per-segment shake with auto-reverting error border. Three classes: .is-error on wrapper and input, .is-shaking on input only.
<div class="t-error-wrap">
<input class="t-error-input" />
<p class="t-error-msg">Invalid email</p>
</div>@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(var(--shake-dist)); }
50% { transform: translateX(calc(-1 * var(--shake-overshoot))); }
75% { transform: translateX(calc(var(--shake-dist) * 0.5)); }
100% { transform: translateX(0); }
}
.t-error-input {
transition: border-color var(--shake-revert-dur) ease;
}
.t-error-input.is-error {
border-color: var(--color-error, #ef4444);
}
.t-error-input.is-shaking {
animation: shake
calc(var(--shake-dur-1) + var(--shake-dur-2) + var(--shake-dur-3))
var(--shake-ease);
}
.t-error-msg {
opacity: 0;
transform: translateY(-4px);
transition: opacity 150ms ease, transform 150ms ease;
}
.t-error-wrap.is-error .t-error-msg {
opacity: 1;
transform: translateY(0);
}JS, trigger and auto-revert:
Keep .is-error and .is-shaking separate. .is-shaking controls only the shake animation, removed on animationend. .is-error controls the border colour and message visibility, auto-reverting after the hold duration.
function triggerError(wrap, input) {
wrap.classList.add("is-error");
input.classList.add("is-error", "is-shaking");
input.addEventListener("animationend", () => {
input.classList.remove("is-shaking");
}, { once: true });
const hold = parseFloat(getComputedStyle(wrap).getPropertyValue("--shake-hold"));
setTimeout(() => {
wrap.classList.remove("is-error");
input.classList.remove("is-error");
}, hold);
}