Catalog / Technique
Scroll reveal
Use when content should arrive as the reader scrolls to it, such as a list, a card grid or a section intro. Covers when a reveal helps, when it harms, and the behaviours that keep one from leaving content hidden.
- Loads as
technique-scroll-reveal- Written for
- An agent, to follow
Scroll reveal
Content that fades or moves into place when it enters the viewport. A common effect, and easy to get subtly wrong: every rule below exists because a reveal that fails does not look broken, it leaves a blank page.
When to use
- A list or grid whose items are a sequence, where a stagger helps the eye take them one at a time.
- The first element of a section, to mark that a new section has begun.
Do not use it for:
- Body prose. A paragraph that moves while it is being read is a cost.
- Data tables and figures a reader came to consult.
- Anything visible on first load. That is a page-load effect, a different technique with a different budget.
- More than one level of a nested structure. Reveal the list or its items, never both.
- Content clipped out of view by an ancestor. It can never be scrolled onto the screen, so it never reveals.
Rules
- Reveal once. Replaying on the way back up turns a flourish into a tic.
- Hide only from script, and only after the checks pass. If the code that hides content is the same code that shows it, there is no load failure that leaves a page blank. Never hide with a stylesheet rule that waits for a script to arrive.
- Never ask for more of an element than the screen can show. A
visibility threshold is a share of the element. An element two screens
tall is never more than half visible, and an observer waiting for more
never fires and never errors. Cap the requirement by the screen size,
and take the screen size from the document’s own window: inside an
iframe the observer’s
rootBoundsdescribes the top window instead. - Watch each item, not the list, when the list can outgrow the screen. One observer on a container reveals every row when its first sliver appears, including the rows nobody can see yet.
- Honour reduced motion by not starting. Check
prefers-reduced-motion: reducebefore touching the element, and leave it exactly as found. - Animate opacity and transform only. Anything else moves layout and shifts what the reader is looking at.
- Animate from hidden, not to visible. Give only the starting keyframe, so an item ends at its own opacity and transform.
- Keep travel short. 8 to 24 px. Longer reads as the page being unstable.
- Cap the stagger. Past five or six items arriving together, later ones share a delay. Nobody should wait a second for the end of a row.
- Detect with IntersectionObserver. Never a scroll listener.
- One mount to an element. Mounting again must replace the first, or the second records the hidden state as the original.
- Clean up. Stop observing each item as it arrives, and show whatever is still hidden on destroy.
Decisions
| Question | Choose | Because |
|---|---|---|
| Each item in its own time, or the group as one? | Per item for anything that can be taller than the screen. As one for a short group that reads as a unit | A reader should see what arrives. A row of three cards arriving one scroll-tick apart reads as lag |
| CSS scroll timeline or IntersectionObserver? | IntersectionObserver, unless the project targets only browsers with scroll timelines | A reveal hides content until it fires. That is the wrong thing to stake on partial support |
CSS animation or Element.animate? |
Element.animate in a copy-in recipe, CSS in a project with its own stylesheet |
Script animation needs no injected styles and is removed with the handle |
| What duration? | The design system’s own duration token if it has one | A reveal should move at the speed everything else on the site moves |
| Threshold? | Low, 0.05 to 0.15 | High thresholds delay the reveal until the item is nearly past the reader |
Tuning
| Dial | Start | Range | Low end | High end |
|---|---|---|---|---|
| Travel | 16 px | 8 to 24 | A fade with a hint of settling | The page looks unstable |
| Duration | 600 ms | 300 to 900 | A flick, easy to miss | The reader waits for the list |
| Stagger | 70 ms | 0 to 120 | Items arrive as one block | Reads as lag |
| Stagger ceiling | 350 ms | 200 to 600 | Long rows arrive nearly together | The last item is noticeably late |
| Threshold | 0.08 | 0.05 to 0.15 | Fires at the first sliver | Fires when the item is nearly past |
Rest state
The content, exactly as it is without the recipe. Under reduced motion, without the observer or the animation API, and in print, nothing is hidden and nothing moves.
Cost
- To build: small. The care goes into the cases where it never fires, not into the animation.
- To run: one observer, one short animation per item, on the compositor. About 1.3 kB gzipped.
- To the reader: a wait before a list can be read. Keep the total under half a second for anything a reader came for.
Checklist
- With JavaScript disabled, all content is visible.
- With reduced motion requested, nothing moves and nothing is hidden.
- A list much taller than the screen reveals, top to bottom.
- On a phone-sized viewport, every item reveals at every threshold the controls allow.
- Rows below the fold have not already arrived when the reader reaches them.
- It has been run in a real browser, and inside an iframe if it will ever be shown in one. A faked observer reports whatever the test tells it to.
- Scrolling back up does not replay it.
- Mounting twice on one element leaves its content visible.
- No layout shift is recorded while it plays.
- Nothing nested is revealed twice.
- Observers are released once everything has arrived.
Recipes
reveal: children of an element arrive as they scroll into view, per item or as one.
Sources
mdn-intersection-observer: detecting entry without a scroll listener, and what a threshold is a ratio ofmdn-element-animate: animating from script, andfillduring a delaymdn-prefers-reduced-motion: the media feature to readwcag-animation-from-interactions: why scroll-triggered motion must be possible to switch off