Reveal an element the first time it enters the viewport
Canonical URL: https://devexamples.com/javascript/reveal-an-element-when-it-enters-the-viewport/
The problem
Sections lower down a long page should ease in as the reader reaches them, but a scroll handler that measures element positions on every event is expensive and an animation started at page load has usually finished before anyone is looking. Assumes one marked class of blocks, content that must remain readable when the script never runs, and a reveal that plays once rather than on every entry.
Short answer
Add the class that gates the hidden state, hand every marked block to one IntersectionObserver with a threshold, apply the visible class when entry.isIntersecting is true, then unobserve that block so the transition can never replay.
A reveal that waits for the reader is a visibility question, and the platform now answers visibility questions with IntersectionObserver instead of with arithmetic inside a scroll handler. The effect itself belongs to CSS; the script only has to say when a block has earned its visible state, and to stop asking about it afterwards.
<section class="panel" data-reveal>
<h2>Fetch and error handling</h2>
<p>A failed request and a failed status need different answers.</p>
</section>
<section class="panel" data-reveal>
<h2>Deployment</h2>
<p>The same artifact serves preview and production.</p>
</section>.js-reveal [data-reveal] {
opacity: 0;
transform: translate3d(0, 1.5rem, 0);
transition: opacity 400ms ease-out, transform 400ms ease-out;
}
.js-reveal [data-reveal].is-revealed {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.js-reveal [data-reveal],
.js-reveal [data-reveal].is-revealed {
opacity: 1;
transform: none;
transition: none;
}
}// Everything that hides content sits behind this class, so a page whose script
// never runs simply shows its sections.
document.documentElement.classList.add('js-reveal');
const revealTargets = document.querySelectorAll('[data-reveal]');
function reveal(target, obs) {
target.classList.add('is-revealed');
if (obs) {
obs.unobserve(target); // first entry only: nothing left to report
}
}
if (typeof IntersectionObserver !== 'function') {
for (const target of revealTargets) {
reveal(target, null); // no observer means no waiting, not no content
}
} else {
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) {
continue; // this entry is an exit, not an arrival
}
reveal(entry.target, obs);
}
}, { threshold: 0.25 });
for (const target of revealTargets) {
observer.observe(target);
}
}Explanation
The transition lives in CSS and the decision lives in the observer, which is the division that makes this cheap. threshold: 0.25 asks for a quarter of each section’s own area to be inside the viewport before the callback delivers anything, so the reveal lands while the block is genuinely being read rather than the moment its first row of pixels appears. Entries arrive in batches, and a batch can contain both arrivals and departures, because the observer reports transitions in both directions; that is why the loop tests entry.isIntersecting before doing anything, and why a reveal written directly against “the callback fired” animates at the wrong time on the way up. unobserve then removes just that element from the observer’s list, so the visible state is written once and the remaining sections keep being watched.
translate3d is used instead of a plain translateY because a reveal is a movement that happens while the page is already scrolling, and the third argument pushes the animated element onto its own compositing layer where the browser can move it without repainting its neighbours. The duration is short and the distance is small for the same reason: a long slide reads as lag when the reader is in motion. The reduced-motion block resolves the whole mechanism to a static state by overriding both the hidden and revealed rules, so the content is simply there, with no transition for the preference to shorten.
The interesting boundary is what happens when this script does not run, and the class gate is the answer to it. Hiding content in the stylesheet alone is the single worst failure mode of scroll reveals: blocked script, disabled JavaScript, a throwing earlier module, or a race between the stylesheet and the bundle all leave the reader with blank space where the page’s body should be. Gating the hidden rule behind a class that the reveal script itself adds means the failure state is a plain, fully visible page; the cost is that those sections can paint for one frame before the class lands, which is a far better outcome than content that never arrives. If that flash matters, the same class can be added by an earlier declaration in the document head, at the price of a blocking script, and that trade is worth making deliberately rather than by accident. The second boundary is geometry: a threshold is a fraction of the element, so a section taller than the viewport can never reach a quarter visible on a phone, and the fix is a smaller threshold or a sentinel element, not more script.
Next steps
A reveal answers when a block becomes decorative-visible. The same observer shape solves a heavier problem when the answer decides a network request, which is what the image example does with a margin band in place of a threshold, and the pairing of one observer plus one delegated listener is what keeps both patterns working after content is added to the page.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
data-reveal | presence attribute on the block | Yes | — | Marks the sections the script registers, and scopes the hidden styling to exactly those elements. |
is-revealed | class name added by the script | Yes | — | The end state the transition animates towards; it is added once and never removed. |
threshold | number between 0 and 1 | No | 0.25 in the code above | Share of the element's own area that must be inside the viewport before the reveal fires. |
Expected output
Each marked section fades and rises into place the first time roughly a quarter of it is on screen, holds that position when the reader scrolls away and back, and shows no hiding at all when script does not run or reduced motion is requested.
Usage notes
- Reveal blocks that are decoration or prose rhythm; a block the reader needs immediately should not wait for a scroll to become visible.
- Keep the transform small and the transition short, because the animation is happening while the reader is moving.
- Register blocks added later by calling observe() on them; the observer holds no memory of elements it was never given.
Common mistakes
- Measuring element offsets inside a scroll handler, then paying for a layout read on every frame of the drag.
- Writing the hidden state into the stylesheet with no gate, so a reader whose script fails is left with blank space.
- Leaving the element observed after the reveal, which replays the animation every time the reader scrolls back through it.
Caveats
- A block held at opacity 0 is still in the accessibility tree and still focusable, so never hide a control behind a reveal that depends on scrolling to lift.
- A target taller than the viewport may never satisfy a quarter-of-its-own-area threshold; give long sections a smaller threshold or observe a short sentinel element inside them.
- The first entry list is delivered after layout, so sections already on screen animate a frame or two after the page paints rather than arriving already settled.
Follow-up
Follow-up
Lazy-load images with IntersectionObserverThe same one-shot observer is what defers an image request until the image is nearly on screen.
Related examples
Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.
Related
Delegate events for a list that changes after loadOne observer watching many sections keeps a fixed handler count exactly as one delegated listener serves a changing list.
Same task or topic
Lazy-load images with IntersectionObserverSolves the Observe element visibility task
Same task or topic
Add an event listener with the options you actually needShares the DOM Events topic
References
- IntersectionObserverEntry: isIntersecting property(opens in a new tab) — MDN Web Docs. Defines the flag that separates an element entering the root from one leaving it.
- IntersectionObserver: unobserve() method(opens in a new tab) — MDN Web Docs. Stopping observation of one target while the observer keeps watching the rest.
- prefers-reduced-motion CSS media feature(opens in a new tab) — MDN Web Docs. The user preference that motion-heavy reveals must resolve to a static state.
Source page: https://devexamples.com/javascript/reveal-an-element-when-it-enters-the-viewport/