# Scroll Reveal (/docs/display/scroll-reveal)



<ComponentShowcase name="scroll-reveal" />

## Installation [#installation]

```bash
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-reveal.json
```

## Prompt [#prompt]

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

```text
Build a React + TypeScript + Tailwind "ScrollReveal" component — a generic
entrance wrapper for arbitrary children (not a text-splitting effect).

Contract
- Export a forwardRef component extending
  Omit<React.HTMLAttributes<HTMLElement>, "children">, rendered as `as`
  (default "div") so it can wrap block or inline content.
- Props: children (ReactNode, required), variant = "fade" | "slide-up" |
  "slide-left" | "blur" | "scale" (default "fade"), distance (px, only used
  by the two "slide-*" variants, default 24), duration (ms, default 600),
  delay (ms, default 0), stagger (ms; when > 0, wraps each direct child and
  staggers its delay by index * stagger + delay, default 0), once (boolean,
  default true — reveal once and stop observing; false resets to hidden on
  exit and replays on every re-entry), threshold (IntersectionObserver
  threshold, default 0.15), rootMargin (string), as (React.ElementType,
  default "div"), disabled (boolean — fully turns the effect off).

Behavior
- Attach ONE IntersectionObserver to the root node (default browsers already
  clip intersection through scrollable ancestors, so this works unmodified
  inside a nested scroll container, not just the page viewport). On the
  first intersecting entry, set revealed = true; if once, disconnect right
  there. If once is false, also flip revealed back to false when an entry
  leaves, so the next entry replays from scratch. Disconnect on unmount.
- Two independent booleans gate whether the observer is even created:
  reduced-motion (via useSyncExternalStore on matchMedia, server snapshot
  false) and "IntersectionObserver support confirmed" (also via
  useSyncExternalStore, server AND first-client-paint snapshot false,
  flips true only once React confirms it post-hydration). Only when neither
  is a problem AND disabled is false does the component ever render its
  hidden ("idle") appearance or start observing.
- stagger <= 0: the hidden/visible state and the animation apply directly
  to the root element, delay = delay.
- stagger > 0: React.Children.map wraps every direct child in a plain div
  carrying its own hidden/visible state, delay = index * stagger + delay.
  A real wrapper element is required — `display: contents` boxes cannot be
  animated (there is no box to apply transform/opacity/filter to) — so this
  costs one extra block-level DOM node per child.
- Each variant maps to two CSS custom properties set inline
  (--sr-from-transform / --sr-from-filter): fade sets neither; slide-up sets
  translateY(distance); slide-left sets translateX(distance); blur sets
  blur(8px); scale sets scale(0.92). ONE shared @keyframes animates from
  those custom properties (opacity 0) to the fully-settled state (opacity
  1, transform/filter none) — variants don't need five separate keyframes.

Rendering & styling
- Semantic tokens only — this component sets no color of its own, it only
  moves/fades/blurs whatever children already look like.
- THE SAFETY RULE: the static "hidden" appearance (opacity 0 + the from-
  transform/from-filter) is written as a CSS rule scoped inside
  `@media (prefers-reduced-motion: no-preference)`, keyed off a
  `data-scroll-reveal="idle"` attribute that the component only ever
  renders once both gating booleans above have confirmed it's safe. This
  guarantees content defaults to fully visible for reduced-motion users,
  for any environment where JS never runs, and for browsers lacking
  IntersectionObserver — never a permanently-invisible section.
- Ship the @keyframes + media-scoped rule via a React 19 hoisted
  <style href precedence> tag — no Tailwind config edits, dedupes by href.
- cn() merges consumer className; accessibility needs nothing extra since
  the real children stay in the DOM the whole time (no text splitting, no
  aria-hidden duplication) — only their opacity/transform/filter changes.

Customization levers
- Variant: pick fade for subtlety, slide-up/slide-left for directional
  motion, blur or scale for a softer/punchier settle — swapping is a prop.
- Pace: duration 400-600ms suits small cards, 700-900ms suits full
  sections; stagger 60-100ms reads as a natural cascade for card grids or
  lists, lower feels simultaneous, higher feels sequential/deliberate.
- Distance: 16-24px for tight UI (list rows, small cards), 40-60px for
  hero-scale blocks — only affects the two slide variants.
- Threshold/rootMargin: lower threshold or a positive rootMargin
  (e.g. "0px 0px -10% 0px") triggers the reveal earlier, before the block
  is fully in view — useful for tall sections.
- Replay: once=false for demo/spec sections a user scrolls past repeatedly;
  once=true (default) for one-shot marketing entrances.
- Composition: wrap a whole section (as="section") or a single card; add
  stagger only when direct children are meant to read as one group cascading
  in together, not as independently-triggered entrances.
```

## Concepts [#concepts]

<Mermaid
  chart="`flowchart TD
A[&#x22;props: variant + distance<br/>+ duration + delay + stagger&#x22;] --> B[&#x22;one IntersectionObserver<br/>on the root node&#x22;]
B -->|&#x22;threshold crossed, enters view&#x22;| C[&#x22;setRevealed(true)&#x22;]
C --> D[&#x22;once? disconnect observer<br/>: keep watching for exit&#x22;]
D -->|&#x22;once=false, leaves view&#x22;| E[&#x22;setRevealed(false) — resets for replay&#x22;]
A --> F{&#x22;stagger > 0?&#x22;}
F -- no --> G[&#x22;hidden/visible state on the root itself&#x22;]
F -- yes --> H[&#x22;React.Children.map wraps each child<br/>delay = index × stagger + delay&#x22;]
I[&#x22;prefers-reduced-motion:<br/>no-preference&#x22;] -->|&#x22;gates the hidden rule&#x22;| J[&#x22;data-scroll-reveal=idle&#x22;]
K[&#x22;JS not hydrated yet, or<br/>IntersectionObserver unsupported&#x22;] -->|&#x22;never renders idle&#x22;| L[&#x22;content stays visible by default&#x22;]`"
/>

* **Root-observed, not per-child** — a single `IntersectionObserver` watches the wrapper itself; `stagger` only changes each child's `animation-delay`, it never spawns one observer per child.
* **Scoped hidden rule, not a default look** — the only CSS that hides content lives inside `@media (prefers-reduced-motion: no-preference)`, keyed to a `data-scroll-reveal="idle"` attribute the component renders conditionally — so reduced-motion visitors, and any render where JS/IntersectionObserver never confirmed support, simply never see the hidden state at all.
* **One keyframe, five variants** — every variant is just two CSS custom properties (`--sr-from-transform`, `--sr-from-filter`) fed into one shared `@keyframes`; adding a sixth variant means adding one more custom-property mapping, not a new keyframe.
* **Stagger via a wrapper, not a clone** — `display: contents` can't be animated, so staggering a group of children costs one extra block-level `div` per child; documented as the honest tradeoff rather than hidden.
* **Once vs. replay** — `once` decides whether the observer disconnects after the first reveal or keeps watching to reset on exit, turning the same entrance into either a one-shot moment or a repeatable scroll effect.
* **Clipped intersection works inside nested scroll containers** — because browsers compute `IntersectionObserver` intersection accounting for clipping introduced by scrollable ancestors, the default (viewport) root already works correctly inside an `overflow-auto` panel, not just the page itself.
