Transitions & Animations
Transitions — Animate State Changes
A transition smooths a property change between two states (hover, focus, class toggles):
.btn {
background: #6366F1;
transform: translateY(0);
transition: background 0.2s ease, transform 0.2s ease;
}
.btn:hover {
background: #4F46E5;
transform: translateY(-2px);
}Syntax: transition: property duration easing delay. List specific properties — transition: all animates things you didn’t intend and costs performance.
Easing
transition-timing-function: ease; /* default — fine */
transition-timing-function: ease-out; /* fast start, gentle stop — best for UI */
transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); /* springy, custom */UI rule of thumb: 150–250ms with ease-out. Under 100ms feels broken, over 400ms feels slow.
Animate Only transform & opacity
Animating width, height, top, or margin forces the browser to recalculate layout every frame — janky on weak devices. transform and opacity run on the GPU compositor and stay at 60fps:
/* janky */ /* smooth — same visual */
left: 20px; transform: translateX(20px);
width: 110%; transform: scale(1.1);
top: -8px; transform: translateY(-8px);Keyframe Animations — Multi-Step Motion
Transitions need a trigger; animations run on their own:
@keyframes fadeUp {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.hero h1 {
animation: fadeUp 0.8s ease-out both;
}
/* staggered entrance */
.card:nth-child(2) { animation-delay: 0.1s; }
.card:nth-child(3) { animation-delay: 0.2s; }@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.live-dot {
animation: pulse 2s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 0.8s linear infinite;
}Key properties: animation-fill-mode: both (hold the first/last frame — prevents the “flash before animation” bug), animation-iteration-count: infinite, animation-direction: alternate.
Scroll-Triggered Reveals
The classic pattern pairs CSS with a tiny IntersectionObserver:
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}const io = new IntersectionObserver((entries) => {
entries.forEach(e => e.isIntersecting && e.target.classList.add("visible"));
});
document.querySelectorAll(".reveal").forEach(el => io.observe(el));Respect Reduced Motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}Some users get motion sickness from animation. This query is part of shipping professional CSS, not an optional extra.