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.
Loading preview…
- Registry dependencies
- utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-reveal.jsonPrompt
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
IntersectionObserverwatches the wrapper itself;staggeronly changes each child'sanimation-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 adata-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: contentscan't be animated, so staggering a group of children costs one extra block-leveldivper child; documented as the honest tradeoff rather than hidden. - Once vs. replay —
oncedecides 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
IntersectionObserverintersection accounting for clipping introduced by scrollable ancestors, the default (viewport) root already works correctly inside anoverflow-autopanel, not just the page itself.
Virtual List
A hand-rolled windowed list — renders only the visible rows (+ overscan) of a huge array, with fixed or measured variable row heights, imperative scroll control, and infinite-scroll support.
Data Table
A contract-driven interactive data table — controlled sort, tri-state selection, sticky header, row actions, and real loading/empty/error states.