Skip to main content

React Sticky Reveals Tutorial

Sticky reveals combine two techniques: elements that stick to the viewport as the user scrolls, and animations that trigger when they come into view. A typical pattern is a navigation bar that becomes sticky once the user scrolls past it, or a section headline that slides in and stays visible as the page scrolls beneath it. These effects enhance storytelling and guide user attention through the page without traditional navigation menus.

Building a Sticky Header with IntersectionObserver

The most performant way to detect when an element should "stick" is IntersectionObserver. When the target element leaves the viewport, a sticky version appears:

import { useEffect, useRef, useState } from 'react';

export default function StickyHeader() {
const triggerRef = useRef(null);
const stickyRef = useRef(null);
const [isSticky, setIsSticky] = useState(false);

useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsSticky(!entry.isIntersecting);
},
{ threshold: 0 }
);

if (triggerRef.current) {
observer.observe(triggerRef.current);
}

return () => {
if (triggerRef.current) {
observer.unobserve(triggerRef.current);
}
};
}, []);

return (
<>
{/* Trigger element — when this leaves viewport, sticky header appears */}
<div
ref={triggerRef}
style={{
height: '60px',
backgroundColor: '#f9f9f9',
display: 'flex',
alignItems: 'center',
paddingLeft: '16px',
}}
>
<h1 style={{ margin: 0, fontSize: '24px' }}>Main Header</h1>
</div>

{/* Sticky header — appears when trigger leaves viewport */}
<div
ref={stickyRef}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
height: '60px',
backgroundColor: '#fff',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
paddingLeft: '16px',
transform: isSticky ? 'translateY(0)' : 'translateY(-100%)',
transition: 'transform 0.3s ease-out',
zIndex: 100,
}}
>
<h1 style={{ margin: 0, fontSize: '18px' }}>Sticky Header</h1>
</div>

{/* Content */}
<div style={{ padding: '40px', minHeight: '1500px' }}>
<p>Scroll down to see the sticky header appear.</p>
{Array.from({ length: 30 }, (_, i) => (
<p key={i}>Paragraph {i + 1}</p>
))}
</div>
</>
);
}

The IntersectionObserver detects when the trigger element (the main header) leaves the viewport. When it does, the sticky header animates in with a smooth transform.

Scroll-Triggered Reveal Animations

Reveal animations show elements as the user scrolls to them. Use IntersectionObserver to detect visibility and apply CSS animations:

import { useEffect, useRef } from 'react';

export default function ScrollReveal() {
const sectionRefs = useRef([]);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.opacity = '1';
entry.target.style.transform = 'translateY(0)';
}
});
},
{ threshold: 0.1 }
);

sectionRefs.current.forEach((ref) => {
if (ref) observer.observe(ref);
});

return () => {
sectionRefs.current.forEach((ref) => {
if (ref) observer.unobserve(ref);
});
};
}, []);

return (
<div>
{Array.from({ length: 5 }, (_, i) => (
<div
key={i}
ref={(el) => {
if (el) sectionRefs.current[i] = el;
}}
style={{
padding: '60px 20px',
minHeight: '300px',
backgroundColor: i % 2 === 0 ? '#f3f4f6' : '#fff',
opacity: 0,
transform: 'translateY(20px)',
transition: 'all 0.6s ease-out',
}}
>
<h2 style={{ fontSize: '28px', marginBottom: '16px' }}>
Section {i + 1}
</h2>
<p>
This section appears as you scroll. The animation combines opacity
and translation for a smooth reveal.
</p>
</div>
))}
</div>
);
}

Each section starts with opacity: 0 and translateY(20px), then transitions to full opacity and normal position when IntersectionObserver detects it in the viewport.

Staggered Reveal Animation for List Items

For lists or grids, stagger the reveal timing so items appear in sequence:

import { useEffect, useRef } from 'react';

export default function StaggeredReveal() {
const itemsRef = useRef([]);

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const index = itemsRef.current.indexOf(entry.target);
entry.target.style.animationDelay = `${index * 0.1}s`;
entry.target.classList.add('reveal');
}
});
},
{ threshold: 0.1 }
);

itemsRef.current.forEach((ref) => {
if (ref) observer.observe(ref);
});

return () => {
itemsRef.current.forEach((ref) => {
if (ref) observer.unobserve(ref);
});
};
}, []);

return (
<div style={{ padding: '40px' }}>
<style>{`
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.reveal {
animation: slideIn 0.5s ease-out forwards;
}
`}</style>

<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '20px' }}>
{Array.from({ length: 9 }, (_, i) => (
<div
key={i}
ref={(el) => {
if (el) itemsRef.current[i] = el;
}}
style={{
padding: '20px',
backgroundColor: '#e0f2fe',
borderRadius: '8px',
opacity: 0,
}}
>
<h3>Item {i + 1}</h3>
<p>Reveals with staggered timing.</p>
</div>
))}
</div>
</div>
);
}

Each item gets an animation delay based on its index, so the first reveals immediately, the second after 100ms, and so on. This creates a waterfall effect that feels natural and engaging.

Key Takeaways

  • Use IntersectionObserver to detect when elements enter the viewport; it's more performant than scroll listeners for reveal animations.
  • Combine opacity and transform (e.g., translateY) in reveal animations; avoid height-based transitions, which trigger repaints and are slower.
  • For sticky headers, use a trigger element with IntersectionObserver to toggle the sticky state, avoiding scroll event overhead.
  • Stagger reveal animations with CSS animation-delay to create engaging, sequential transitions for lists and grids.

Frequently Asked Questions

Why use IntersectionObserver instead of scroll events for reveals?

IntersectionObserver is more efficient: the browser batches visibility checks and only fires when intersection status changes, not on every scroll frame. Scroll events fire dozens of times per second, causing performance issues on long pages with many elements.

Can I use CSS position: sticky instead of JavaScript?

CSS sticky works great for simple headers, but it lacks animation control. If you need slide-in animations or conditional logic, JavaScript is necessary. You can combine both: use CSS sticky for basic sticking and JS for animations.

How do I ensure reveal animations don't fire multiple times?

After firing the animation once, remove the element from the observer (observer.unobserve(element)). Or add a data-revealed attribute and skip already-animated elements in your intersection handler.

Should I use transition or animation for reveals?

Transitions are simpler (set initial state, then update properties). Animations (CSS @keyframes) are better for complex sequences or staggered timing. For most reveals, transitions are sufficient.

How do I customize reveal direction?

Modify the initial transform value: translateX(-20px) (from left), translateX(20px) (from right), translateY(20px) (from bottom). You can also use scale(0.8) (zoom in) or rotate() (spin) for variety.

Further Reading