Scroll Effects

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.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add sticky-reveal.en

New project or installation trouble? Start here →

Sticky Reveal is a React sticky scroll section built with Tailwind CSS and Motion: the text column scrolls normally, the media frame stays pinned with CSS position: sticky, and an IntersectionObserver picks the active step and cross-fades to its image.

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
Active edited usageDefault props
<StickyReveal/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

When to use

  • SaaS feature tours where each paragraph pairs with a product screenshot
  • Onboarding or "how it works" sections that swap a diagram per step
  • Long-form case studies or annual recaps where charts change with the text
  • Hardware or app landing pages showing different screens or angles in turn
  • Not a good fit: continuous animation scrubbed frame by frame with scroll; use Image Sequence Scrub instead

Installation

npx webberui@latest add https://webberui.com/r/sticky-reveal.en.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>

Examples

Media on the left, landscape frame

mediaSide="left" moves the media to the left; mediaClassName overrides the default 3:4 portrait ratio, and stepClassName shortens the scroll distance per step.

<StickyReveal
  mediaSide="left"
  mediaClassName="aspect-video"
  stepClassName="min-h-[50vh]"
  stickyTop={96}
>
  <StickyRevealItem media={<img src="/dashboard.png" alt="Operations dashboard" className="h-full w-full object-cover" />}>
    <h3 className="text-xl font-semibold">Live dashboard</h3>
    <p className="mt-2 text-neutral-600">Orders, revenue and inventory on one page.</p>
  </StickyRevealItem>
  <StickyRevealItem media={<img src="/reports.png" alt="Report export" className="h-full w-full object-cover" />}>
    <h3 className="text-xl font-semibold">Scheduled reports</h3>
    <p className="mt-2 text-neutral-600">Last week's numbers, emailed every Monday morning.</p>
  </StickyRevealItem>
</StickyReveal>

If the page has a fixed navbar, set stickyTop to its height so the frame is not covered.

Controlled: sync an external step label

With activeIndex you own the active step; scrolling still reports through onActiveChange, so write the value back to state.

const steps = ["Connect account", "Import data", "Invite team"];
const [step, setStep] = React.useState(0);

<p className="text-sm text-neutral-500">Step: {steps[step]}</p>
<StickyReveal activeIndex={step} onActiveChange={setStep} showProgress={false}>
  {/* ...StickyRevealItem */}
</StickyReveal>

Custom progress with useStickyReveal

Inside a StickyRevealItem's content, useStickyReveal() gives you active and total, which is enough for a "02 / 04" step counter.

"use client";

import { useStickyReveal } from "@/components/ui/sticky-reveal";

function StepCounter() {
  const { active, total } = useStickyReveal();
  return (
    <span className="font-mono text-xs text-neutral-500">
      {String(active + 1).padStart(2, "0")} / {String(total).padStart(2, "0")}
    </span>
  );
}

Props

StickyReveal

PropTypeDefaultDescription
childrenReactNode—A 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)
activeIndexnumber—controlled mode: the active step index is set from the outside
onActiveChange(index: number) => void—Fires 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)
mediaClassNamestring—Class for the sticky visual frame (override the aspect ratio, corner radius, and so on)
classNamestring—Class for the root container

StickyRevealItem

PropTypeDefaultDescription
mediaReactNode—The paired visual content (an image or graphic)
childrenReactNode—The text content that flows with the scroll
classNamestring—Appended 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.

FAQ

Does it work with the Next.js App Router?

Yes. The file already starts with "use client", so import it from a Server Component page as usual. The IntersectionObserver is created inside an effect, so server rendering is unaffected.

Why doesn't it switch inside an overflow scroll container?

The IntersectionObserver uses the viewport by default. When the content sits in a nested scroll container, pass that container's ref to container; both step detection and the progress-dot scrolling then use it.

How do I change the frame ratio and step length?

The frame defaults to a rounded aspect-[3/4] box; override it with mediaClassName. Each text step defaults to min-h-[70vh]; change it with stepClassName. Shorter steps switch more often.

What does it look like on mobile?

There is no built-in breakpoint: the two columns stay side by side at every width, with the media column fixed at 2/5. To stack them on narrow screens, edit the layout classes in the source.

Does it respect reduced motion?

Yes. With "reduce motion" on, the cross-fade, scale and blur transitions are disabled and clicking a progress dot jumps instantly; the sticky layout stays the same.