Reveal on Scroll
Five text blocks in a 280 px scrolling frame; an IntersectionObserver at 30 % adds the visible class on entry and removes it on exit — a fade and a 30 px rise in 0.6 s, on every pass, in both directions. The observed element carries no transform; the animation is handled by an inner .reveal-inner element, keeping the threshold stable at any scroll position.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
The sold HTML is a .scroll-container#reveal-scroll frame (height: 280px, overflow: hidden auto, scroll-behavior: smooth, 4 px bar styled with ::-webkit-scrollbar) holding six 80 px .reveal-spacer elements alternating with five .reveal-item blocks carrying the data-reveal attribute: a 40 × 3 px .reveal-accent line (gradient #22d3ee → #6366f1), a 0.9 rem h4 in 700 white, a 0.75 rem p at 45% white. The first block already carries visible in the HTML. At rest, .reveal-item .reveal-inner sits at opacity: 0 and transform: translateY(30px) with transition: .6s cubic-bezier(.16, 1, .3, 1) — the shorthand without a property, hence all; .reveal-item.visible .reveal-inner restores opacity 1 and translation 0. .reveal-item itself carries no transform: it stays at its natural layout position so the observer always measures the same rectangle. Below the frame, .scroll-hint-label (“Scrollez pour explorer”, 0.65 rem uppercase) pulses from 0.3 to 0.7 opacity every 2 s, in position: absolute; bottom: 8px relative to the .demo-preview wrapper. Measured on a blank 1,280 px page: 1,052 px of content, a 772 px run, 114 px blocks; at 390 px: 1,148 px, an 868 px run, 134 px blocks.
The script creates a single IntersectionObserver with root: revealScroll (the frame, not the window), threshold: .3 and rootMargin: "0px", and observes every [data-reveal] in the document; on each entry, isIntersecting true adds visible, false removes it. Measured with the wheel (60 px steps, identical in Chromium, Firefox and WebKit): visible sets [0, 1] → [1] → [1, 2] → [2] → [2, 3] → [3] → [3, 4] → [4] on the way down, and the reverse sequence back to [0] on the way up — every block replays on every pass, which is the advertised bidirectionality. The transition: 53% of the opacity reached at 84 ms, 83% at 167 ms, 93% at 250 ms, 99.9% at 500 ms; the translation goes from 30 to 14 px within the first 84 ms — the (.16, 1, .3, 1) curve covers almost the whole distance in a quarter of a second and finishes slowly. One design detail matters for the threshold: the observer measures the .reveal-item box, which carries no transform — the animation is delegated to .reveal-inner. The 30 % threshold therefore applies to the element’s natural layout position. For an 81 px block: the class arrives when 24 px have entered the frame, and it leaves when fewer than 24 px remain — exact symmetry on entry and exit. Measured in Chromium and Firefox: below 0.3, the entry arrives with isIntersecting false.
The code deliberately separates the observed element from the animated element. .reveal-item is the one the IntersectionObserver measures: its rectangle matches its natural layout position, with no transform applied. .reveal-inner is the one that animates: it holds opacity: 0 and transform: translateY(30px) at rest, and switches to opacity: 1; transform: translateY(0) when the .reveal-item.visible .reveal-inner selector is active. This separation guarantees that the rectangle measured by the observer never varies with the animation state — the 30 % threshold always applies to the same box, whether a block is mid-transition or at rest. Without this separation, any shift in the observed box (here, 30 px downward when the class is removed) can push the element back into the frame and trigger another threshold crossing — a loop measured at 32 toggles per second in Chromium and 7 in Firefox at certain scroll positions. The effect's CSS also resets the browser's default margins on h4 and p (margin: 0 0 6px and margin: 0): without this, blocks take up more height than expected and the scroll run changes.
Accessibility
- prefers-reduced-motion: handled —
@media (prefers-reduced-motion: reduce)disables the.reveal-innertransition (transition: none) and the label's pulse (animation: none): content appears instantly without motion, the label stays visible but static. Blocks not yet visible remain atopacity: 0until the observer sees them at 30 % — they are still read by screen readers since they are neverdisplay: none. - Contrast measured on
#0a0a0f: title#fff= 19.8:1; paragraphrgba(255,255,255,.45)= 4.5:1 just barely, at 12 px (0.75 rem) — move to.55(6.3:1) or.6(7.3:1). The “Scrollez pour explorer” label swings from 2.6:1 to 9.7:1 over its pulse: decorative, keep itaria-hidden. On a light page, the 45% white paragraph is invisible (1:1): the colors are written for a dark background. - Hidden blocks are at
opacity: 0, neverdisplay: noneorvisibility: hidden: a screen reader reads all five blocks whatever the scroll position, and a link placed inside an invisible block would stay in the tab order. The frame itself has notabindex: give ittabindex="0"and anaria-labelso it can be scrolled from the keyboard everywhere; theh4elements assume an h1–h3 hierarchy above them. The sold text is French without accents (“Systeme”, “Accessibilite”, “reutilisables”).
Browser compatibility
Requires IntersectionObserver with the root option, ES2015 (const, arrow functions, NodeList.forEach) and the two-value form overflow: hidden auto — a browser that does not understand it ignores the declaration and the frame stops scrolling. No animation-timeline or scroll-timeline: the reveal comes from a JavaScript observer and a classic CSS transition, so it works in Firefox (verified: same toggles, same timing), where the category's native CSS scroll-driven effects do nothing. Zero dependencies.
Without JavaScript, the frame scrolls but only the first block (which carries visible in the HTML) is readable: the other four stay at opacity 0. Without IntersectionObserver (Safari < 12.1), same result — the script has no guard and throws. Under prefers-reduced-motion nothing changes: the code does not listen to the preference.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="scroll-container" id="reveal-scroll">
<div class="reveal-spacer"></div>
<div class="reveal-item visible" data-reveal="">
<div class="reveal-inner">
<div class="reveal-accent"></div>
<h4>…</h4>
<p>…</p>
</div>
</div>
<div class="reveal-spacer"></div>
<div class="reveal-item" data-reveal="">
<div class="reveal-inner">
<div class="reveal-accent"></div>
<h4>…</h4>
<p>…</p>
</div>
</div>
<div class="reveal-spacer"></div>
</div>
<span class="scroll-hint-label">Scrollez pour explorer</span>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Threshold (JS — threshold: .3) |
0.3 | Fraction of the .reveal-item box that must be inside the frame: measured, an 81 px block appears when 24 px have entered and disappears below 24 px remaining — a symmetric threshold since the observed element has no transform. .1 reveals right at the edge; .6 waits until the block is well inside — and a block taller than the frame would never cross 0.6. |
Frame margin (JS — rootMargin: "0px") |
0px | Shifts the detection zone without touching the CSS: "0px 0px -60px 0px" delays entry from the bottom by 60 px; a positive value reveals before arrival. With root set, the margin applies to the frame's edges, not the window's. |
Amplitude (CSS — transform: translateY(30px)) |
30 px | Distance of the .reveal-inner rise. 12px for a subtler slide; 0 or none for a fade only, no movement. This value does not affect the geometry of the .reveal-item measured by the observer. |
Duration and curve (CSS — transition: .6s cubic-bezier(.16, 1, .3, 1)) |
0.6 s, very fast ease-out | Measured: 53% at 84 ms, 93% at 250 ms, real end around 500 ms. The shorthand means all: write transition: opacity .6s …, transform .6s … so as not to animate other properties by accident. ease-out .4s gives a more linear arrival. |
Frame and spacers (CSS — .scroll-container 280px, .reveal-spacer 80px) |
280 px, 6 × 80 px | The frame must stay shorter than its content (otherwise nothing scrolls, nothing enters or leaves). The spacers isolate each block; remove them for a dense list — several blocks are then visible at once and each only enters at 30%. |
Element selection (JS — document.querySelectorAll("[data-reveal]")) |
whole document | Measured: a [data-reveal] element placed outside the frame loses its visible class (never intersecting the root). Replace with revealScroll.querySelectorAll("[data-reveal]") to stay inside the frame, and change the id per instance. |
One-way reveal (JS — the classList.remove branch) |
reversible | Replace e.target.classList.remove("visible") with nothing, and add observer.unobserve(e.target) after the add: each block enters once and stays. It also ends the top-exit loop — there is no exit any more. |
Texts (HTML — h4, p, .scroll-hint-label) |
French without accents | Five demo titles and paragraphs and the “Scrollez pour explorer” label live in the HTML: replace them; the label depends on the .demo-preview wrapper being position: relative — without it, measured, it drops to the bottom of the page. |
FAQ
.reveal-item itself carried transform: translateY(30px), removing the class would shift its box 30 px downward — potentially back above the 30 % threshold, which would add the class back, move the box back up, drop it below the threshold again, and so on: a loop measured at 32 toggles per second in Chromium. .reveal-inner animates the appearance without ever touching the box the observer measures, guaranteeing 0 toggles at any resting scroll position.animation-timeline: view() ties each card's entry to its position in the frame, and position: sticky stacks them at 10, 20, 30 px from the top — past cards stay on screen, that is the point, and Firefox ignores animation-timeline. Reveal on Scroll is a JavaScript observer toggling a class at 30%: the transition is timed (0.6 s) rather than tied to the scroll, a block that leaves the frame disappears, and the whole thing works in Firefox, measured identical to Chromium. Pick Card Stack for a staged pile of five cards; pick Reveal on Scroll for a list where past items must make room for the next ones — after fixing the top-exit loop.document.getElementById("reveal-scroll") only takes the first frame, and document.querySelectorAll("[data-reveal]") collects every block in the document to observe them against that single frame. Measured: a [data-reveal] placed outside the frame immediately loses its visible class, since it never intersects that root — on the site's catalog page, the same selector even catches the dated data-reveal="2026-…" cards of the reveal calendar. For several instances, loop over document.querySelectorAll(".scroll-container"), create one observer per frame with root: frame, and observe frame.querySelectorAll("[data-reveal]"). The observer is never disconnected: in an application that mounts and unmounts the frame, keep the reference and call disconnect() on unmount.