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.enSticky 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.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | — | A set of StickyRevealItem elements |
mediaSide | "left" | "right" | "right" | Which side the visual sits on (text left / image right, or the reverse) |
stickyTop | number | 24 | Offset of the sticky visual frame from the top of the container (px) |
activeIndex | number | — | controlled mode: the active step index is set from the outside |
onActiveChange | (index: number) => void | — | Fires when the active step changes |
container | RefObject<HTMLElement | null> | — | Ref of the nested scroll container; when omitted, the window is used |
showProgress | boolean | true | Show clickable step progress dots |
stepClassName | string | "min-h-[70vh]" | Class for each text step container (it stretches out the scroll distance) |
mediaClassName | string | — | Class for the sticky visual frame (override the aspect ratio, corner radius, and so on) |
className | string | — | Class for the root container |
StickyRevealItem
| Prop | Type | Default | Description |
|---|---|---|---|
media | ReactNode | — | The paired visual content (an image or graphic) |
children | ReactNode | — | The text content that flows with the scroll |
className | string | — | Appended to the class of that text step container |
How it works
- An
IntersectionObserverwatches 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
mediaelements 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
activeIndexis omitted, the component manages it internally; when you pass it, you are in charge, and scrolling still reports back throughonActiveChange. StickyRevealItemis declarative only — the actual layout is rendered byStickyReveal, which reads its props, so stickiness and cross-fading stay centrally managed.- Use
useStickyReveal()anywhere in the subtree to readactive/total/isStaticand 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-hiddenso assistive technology does not read them twice. - The progress dots are native
<button>elements, focusable and activatable by keyboard; the active one carriesaria-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.
Related guides
Horizontal Scroll Section
A pinned section that turns vertical scrolling into horizontal movement, with built-in spring feel, a progress bar, and edge fading.
Image Sequence Scrub
The product-page effect that scrubs a set of frames with scroll progress — the view stays pinned while the imagery plays frame by frame.