Display

Scroll Reveal

A variant-driven entrance wrapper — fade, slide, blur or scale any children in once they scroll into view, with an optional per-child stagger.

Preview in your theme

Loading preview…

Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

export type ScrollRevealVariant = "fade" | "slide-up" | "slide-left" | "blur" | "scale"

// One keyframe for all five variants: the "from" comes from per-instance CSS variables
// --sr-from-transform/--sr-from-filter (derived from variant + distance) and the "to" is always
// fully revealed. The idle (hidden) look lives only inside the "no-preference" media query, so
// reduced-motion visitors — and anyone whose JS has not run or who has no IntersectionObserver —
// never match it and keep the content visible. There is no way to end up with content nobody sees.
const KEYFRAMES = `@keyframes sr-in{from{opacity:0;transform:var(--sr-from-transform,none);filter:var(--sr-from-filter,none)}to{opacity:1;transform:none;filter:none}}

Installation

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

Prompt

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

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

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — usePrefersReducedMotion() is the reduced-motion gate
  that keeps the observer from being created and the hidden state from ever
  rendering. If rebuilding without it: a useSyncExternalStore over
  matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and
  a server snapshot of false.
- 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 (usePrefersReducedMotion, server snapshot false) and
  "IntersectionObserver support confirmed" (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

  • 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.

On This Page