WebberUI

Sticky Reveal

A sticky text-and-visual reveal: the text on the left flows with the scroll while the visual on the right stays pinned and cross-fades between states.

Loading preview…
npx shadcn@latest add https://webberui.com/r/sticky-reveal.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

24
<StickyReveal />

Installation

npx shadcn@latest add https://webberui.com/r/sticky-reveal.json

Or, once registries are configured in components.json, install it as @webberui/sticky-reveal.

Usage

Declare each paired step with StickyRevealItem: media holds the sticky visual on the right, children holds the text on the left:

import { StickyReveal, StickyRevealItem } from "@/components/ui/sticky-reveal";

<StickyReveal>
  <StickyRevealItem media={<img src="/design.jpg" alt="Design" className="h-full w-full object-cover" />}>
    <h3 className="text-2xl font-semibold">Design</h3>
    <p className="mt-2 text-neutral-600">Start from semantic tokens, keep one rhythm across the whole site.</p>
  </StickyRevealItem>

  <StickyRevealItem media={<img src="/ship.jpg" alt="Ship" className="h-full w-full object-cover" />}>
    <h3 className="text-2xl font-semibold">Ship</h3>
    <p className="mt-2 text-neutral-600">One-command install, reduced-motion built in.</p>
  </StickyRevealItem>
</StickyReveal>

If the content lives inside a nested scroll container, pass that container's ref through container (it can be omitted for page-level scrolling):

const scrollerRef = React.useRef<HTMLDivElement>(null);

<div ref={scrollerRef} className="h-[80vh] overflow-y-auto">
  <StickyReveal container={scrollerRef} stickyTop={16}>
    {/* ...StickyRevealItem */}
  </StickyReveal>
</div>

Props

StickyReveal

PropTypeDefaultDescription
childrenReactNodeA set of StickyRevealItem elements
mediaSide"left" | "right""right"Which side the visual sits on (text left / image right, or the reverse)
stickyTopnumber24Offset of the sticky visual frame from the top of the container (px)
activeIndexnumbercontrolled mode: the active step index is set from the outside
onActiveChange(index: number) => voidFires when the active step changes
containerRefObject<HTMLElement | null>Ref of the nested scroll container; when omitted, the window is used
showProgressbooleantrueShow clickable step progress dots
stepClassNamestring"min-h-[70vh]"Class for each text step container (it stretches out the scroll distance)
mediaClassNamestringClass for the sticky visual frame (override the aspect ratio, corner radius, and so on)
classNamestringClass for the root container

StickyRevealItem

PropTypeDefaultDescription
mediaReactNodeThe paired visual content (an image or graphic)
childrenReactNodeThe text content that flows with the scroll
classNamestringAppended to the class of that text step container

How it works

  • An IntersectionObserver watches each text step and picks the active one using a thin band across the middle of the scroll container, so exactly one step is active at any moment.
  • All media elements are stacked inside the same sticky frame: the active one fades in while the inactive ones fade out with a slight scale and blur, producing the cross-fade reveal.
  • controlled and uncontrolled modes: when activeIndex is omitted, the component manages it internally; when you pass it, you are in charge, and scrolling still reports back through onActiveChange.
  • StickyRevealItem is declarative only — the actual layout is rendered by StickyReveal, which reads its props, so stickiness and cross-fading stay centrally managed.
  • Use useStickyReveal() anywhere in the subtree to read active / total / isStatic and build a custom progress indicator.

Accessibility

  • Respects prefers-reduced-motion: the cross-fade transition and blur are disabled along with the progress-dot transition, and clicking to jump becomes an instant jump; the layout (stickiness) is unchanged.
  • Inactive visual layers are marked aria-hidden so assistive technology does not read them twice.
  • The progress dots are native <button> elements, focusable and activatable by keyboard; the active one carries aria-current, and clicking smoothly scrolls back to that step.
  • An aria-live="polite" region announces "step N of M" as it changes.

On this page