Entrance Animations
When to Use
Content appearing on scroll should animate in to signal freshness and guide the eye. Two approaches: JavaScript IntersectionObserver (wider support, more control) and CSS-only scroll-driven animations (no JS, progressive enhancement).
Decision
| If you need... | Use... | Why |
|---|---|---|
| Wide browser support (all modern) | IntersectionObserver + CSS class toggle | Works everywhere, fire-once control |
| No JavaScript dependency | Scroll-driven animation-timeline: view() |
Pure CSS, progressive enhancement |
| Staggered card grid reveals | IntersectionObserver + transition-delay |
Precise control over stagger timing |
| Parallax-like scroll effects | animation-timeline: scroll() |
Ties animation progress to scroll position |
Professional vs Cheap Entrance Quality
| Quality | Translate Distance | Duration | Easing |
|---|---|---|---|
| Professional | 16-24px | 300-500ms | ease-out / decel |
| Subtle/premium | 8-12px | 250-350ms | ease-out |
| Cheap/amateurish | 50px+ | 800ms+ | linear or ease |
| Distracting | Any with bounce | 1000ms+ | ease-in-out with overshoot |
Stagger timing rules: - Between sequential items: 50-100ms delay per item - Sweet spot for card grids: 75ms - Max total stagger: ~400ms (after that, early items feel frozen while late items are still arriving)
Pattern
IntersectionObserver approach:
.reveal {
opacity: 0;
transform: translateY(20px);
transition:
opacity var(--duration-slow) var(--ease-emphasized-decel),
transform var(--duration-slow) var(--ease-emphasized-decel);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Staggered children */
.reveal-stagger > .reveal:nth-child(1) { transition-delay: 0ms; }
.reveal-stagger > .reveal:nth-child(2) { transition-delay: 75ms; }
.reveal-stagger > .reveal:nth-child(3) { transition-delay: 150ms; }
.reveal-stagger > .reveal:nth-child(4) { transition-delay: 225ms; }
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // Fire once
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));
CSS-only scroll-driven reveal (no JavaScript):
@keyframes reveal-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.scroll-reveal {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
/* Fallback for browsers without scroll-driven support */
@supports not (animation-timeline: view()) {
.scroll-reveal {
opacity: 1;
transform: none;
}
}
Feature reference: See modern-css → scroll-driven-animations for
animation-timeline,animation-rangesyntax and browser support.
Accessibility note: Both approaches must respect prefers-reduced-motion — replace transform-based reveals with opacity-only crossfade or show content immediately. See Accessibility and Motion.
Common Mistakes
- Translate distance of 50px+ — feels like content is flying in from offscreen; 16-24px is the professional range
- No
@supportsfallback for scroll-driven animations — unsupported browsers show invisible content - Forgetting
observer.unobserve()— elements re-animate every time they scroll in and out - Total stagger exceeding 400ms — early items feel frozen while waiting for late items
- Not testing entrance animations on page load — above-the-fold content should not animate in (it was never "off-screen")
See Also
- Motion Design Tokens — duration and easing values
- Animation Performance —
transform+opacityare compositor-only - Accessibility and Motion —
prefers-reduced-motionhandling - Modern CSS Craft Patterns — scroll-driven progress bar pattern
- Parallax Effects — scroll-driven depth without entrance animation
- Reference: Chrome: Scroll-Driven Animations