Website markup with CSS animations
CSS animations in the browser execute on the compositor thread — a separate thread that doesn't block JavaScript and rendering. Properly written animations don't cause layout recalculation and work at 60 fps even on mobile devices. Poorly written ones kill performance, cause jank, and frustrate users.
CSS transitions vs CSS animations: choosing the right tool
CSS transitions — for states (hover, focus, active). Declarative, concise:
.button {
background-color: #2563eb;
transform: translateY(0);
transition:
background-color 200ms ease,
transform 150ms ease,
box-shadow 200ms ease;
}
.button:hover {
background-color: #1d4ed8;
transform: translateY(-2px);
box-shadow: 0 8px 25px rgb(37 99 235 / 0.4);
}
CSS animations (@keyframes) — for autonomous, looped, or multi-step movements:
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.8;
}
70% {
transform: scale(1.4);
opacity: 0;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.live-indicator::before {
content: '';
position: absolute;
inset: -4px;
border-radius: 50%;
background: currentColor;
animation: pulse-ring 1.8s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
Only compositor-safe properties
Only properties that don't cause reflow/repaint should be animated:
| Property | Composite | Safe |
|---|---|---|
transform |
Yes | Yes |
opacity |
Yes | Yes |
filter |
Partial | Cautiously |
width, height |
No | No, use scale() |
top, left |
No | No, use translate() |
background-color |
No | Only via transition |
clip-path |
Partial | Yes, modern browsers |
Rule: move via translate, scale via scale, rotate via rotate — never top/left/width/height in @keyframes.
will-change: when to apply
/* Correct — on a specific element, remove after animation */
.modal-overlay {
will-change: opacity;
}
.modal-overlay.is-visible {
will-change: auto; /* Free up resources after */
}
/* Incorrect — on everything */
* {
will-change: transform; /* Will exhaust GPU memory */
}
Animation patterns
Skeleton loading
@keyframes skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
Staggered list entrance
.list-item {
opacity: 0;
transform: translateY(16px);
animation: slide-up 400ms ease forwards;
}
@keyframes slide-up {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Stagger via CSS custom property */
.list-item:nth-child(1) { animation-delay: calc(0 * 80ms); }
.list-item:nth-child(2) { animation-delay: calc(1 * 80ms); }
.list-item:nth-child(3) { animation-delay: calc(2 * 80ms); }
/* Or via inline style from JS/templater */
/* style="--index: 3" → animation-delay: calc(var(--index) * 80ms) */
Loading spinner
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgb(0 0 0 / 0.1);
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 600ms linear infinite;
}
Hero section with parallax effect via CSS
.hero {
perspective: 1px;
overflow: hidden;
height: 100svh;
}
.hero-bg {
transform: translateZ(-1px) scale(2);
/* Parallax without JavaScript, only via CSS perspective */
}
Accessibility: prefers-reduced-motion
Mandatory for any project:
/* Basic animation */
.notification {
animation: bounce-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Disable for users with vestibular disorders */
@media (prefers-reduced-motion: reduce) {
.notification {
animation: fade-in 200ms ease;
}
/* Global acceleration of all animations */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Intersection Observer + CSS classes
Animations on viewport appearance — without heavy libraries:
// Minimalist intersection observer
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // Once only
}
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('[data-animate]').forEach((el) => observer.observe(el));
[data-animate] {
opacity: 0;
transform: translateY(24px);
transition: opacity 500ms ease, transform 500ms ease;
}
[data-animate].is-visible {
opacity: 1;
transform: translateY(0);
}
Performance: checklist
-
transformandopacity— only properties in@keyframesfor movement -
will-changeonly on elements with heavy animation, remove after -
animation-fill-mode: bothinstead of duplicating initial state - No more than 20–30 simultaneously animated elements on the page
-
prefers-reduced-motioncovers all animations - Test in Chrome DevTools → Performance → Rendering → Paint flashing
Timeline
Basic CSS transitions (hover states, modal appearances, fade effects): included in basic markup cost. Custom animations (skeleton, staggered lists, hero animations, parallax): 0.5–1 day depending on quantity and scene complexity.







