D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Muddy Double Image at the Seam Between Two Pinned Sections? Dissolve Through One Base Color, Do Not Stack Two Scenes

· · 6 min read
Muddy Double Image at the Seam Between Two Pinned Sections? Dissolve Through One Base Color, Do Not Stack Two Scenes

I caught this one myself. It did not arrive as somebody else's report. At the seam between two pinned sections, two busy scenes were on screen at the same time and the result was a muddy double image.

What makes a seam like this awkward to settle is that both easy options are bad. Keep the overlap and you get that double image. Drop the overlap and the next section visibly slides in, which reads cheap.

The page itself is one long scroll made of five stages in order: the hero, a narrative stage, a centerpiece stage, a gallery, and a closing stage. A stage whose contents animate pins to the screen with sticky top-0 h-svh, moves against scroll progress, then releases and hands the screen to the next one. The seams between those stages were the problem.

The overlap is not an accident

If two scenes are visible at once, then at the moment of handoff both of them really are inside the viewport, with no fade-through-color discipline separating them. Nothing forbids that. The outgoing section has not finished leaving, the incoming one has already arrived, and both are still fully on display.

The more important question is the next one: why are they sharing that space at all?

Because I told them to. The entering section is pulled up by marginTop: -100svh so its pinned stage overlaps the previous section's final viewport, which means the two share the same box. That pull is what makes the next section always pin at the exact moment the current one reaches progress 1.0.

So the overlap is the mechanism, not the defect. What was wrong was not that it exists, it was the decision to let both scenes stay fully visible for as long as it lasted.

The rule: dissolve in place, never slide

After several passes of iteration the rule settled into one sentence. Every seam is a dissolve in place through navy, never a slide.

In practice that means each section fades in from navy the instant it pins, then fades out to navy at the end of its range, so the section after it also emerges from navy rather than from the leftovers of the previous picture. Because its opacity is zero while it rides up, nobody ever sees the ride.

The navy is not a color I invented for the transition. It is the stage color the page already uses.

One wrapper for every seam

I wrapped all of it into one generic component so every seam behaves identically. That wrapper ended up recorded as one of the shared primitives that came out of the build.

'use client';
 
import { useRef } from 'react';
import { motion, useScroll, useTransform } from 'motion/react';
 
type SeamDissolveProps = {
  z?: number;
  fadeOut?: boolean;
  children: React.ReactNode;
};
 
export default function SeamDissolve({ z = 10, fadeOut = true, children }: SeamDissolveProps) {
  const ref = useRef<HTMLDivElement>(null);
 
  const { scrollYProgress } = useScroll({
    target: ref,
    offset: ['start start', 'end end'],
  });
 
  const opacity = useTransform(
    scrollYProgress,
    fadeOut ? [0, 0.06, 0.94, 0.995] : [0, 0.06],
    fadeOut ? [0, 1, 1, 0] : [0, 1]
  );
 
  return (
    <motion.div
      ref={ref}
      style={{ opacity, marginTop: '-100svh', position: 'relative', zIndex: z }}
    >
      {children}
    </motion.div>
  );
}

The offset ['start start', 'end end'] is what makes those numbers readable: progress 0 is the pin moment, and progress 1 is the next section's pin moment. So the first six percent is the arrival, the dissolve starts at 0.94, and it finishes at 0.995, slightly before the range actually runs out.

marginTop: -100svh is what pulls this section up so its stage shares a box with the previous section's final viewport. The wrapper style is only four properties: opacity, marginTop, position relative, and zIndex from the z prop.

The z prop climbs per section, and that is what decides who covers whom during the overlap. It defaults to 10.

With fadeOut turned off, the keyframes keep the arrival half only, [0, 0.06] to [0, 1]. That is what the final section uses so that it stays.

What is wrapped and what is not

Not every stage goes through the wrapper.

The hero needs no fade in, since it is first and there is nothing before it. All it has is its own fade out at the end: stage opacity 1 to 0 together with scale 1 to 1.06, both across [0.84, 0.985].

The second stage is not wrapped either. In the page file it is a plain div with the class relative z-10 and an inline marginTop: '-100svh', while its entry ramp, [0, 0.06] to [0, 1], lives inside its own component, because it still owns an end fade and its own handoff into the gallery. Its background was also moved onto the sticky element inside it, with the section element made transparent. The 10 on this stage is a Tailwind class, not a prop value, and that is an easy difference to mix up when you are reading old notes.

The remaining three stages are the ones that actually use the wrapper directly in the page file, with z at 15, 20, and 30.

<HeroStage />
 
<div className="relative z-10" style={{ marginTop: '-100svh' }}>
  <NarrativeStage />
</div>
 
<SeamDissolve z={15}><CenterpieceStage /></SeamDissolve>
<SeamDissolve z={20}><GalleryStage /></SeamDissolve>
<SeamDissolve z={30} fadeOut={false}><ClosingStage /></SeamDissolve>

The part I was most worried about

My biggest worry was never the opacity itself, it was what happens to the children. This wrapper puts opacity on an element that contains position: sticky descendants, and my fear was that opacity behaves like transform and filter, which change the containing block of their descendants. If sticky broke, the whole page mechanic broke with it.

It holds up, and that is verified rather than assumed: opacity on the wrapper does not break position: sticky pinning on the child. The pinned child really is sticky top-0 h-svh inside that faded wrapper, and it keeps pinning the way it did before. Opacity below one does create a stacking context, but it does not create a new containing block, so sticky still resolves against the same scroll box.

How to check it, and who gets to close the case

Screenshots of this page are useless. They stall, because the requestAnimationFrame loops from smooth scrolling and from motion never go idle. So I verified the scroll behavior through browser_evaluate instead: measuring the top of each section and its overlaps, then scrolling to a given point and confirming that the sticky element's rect.top sits near zero.

The one thing no tool reports is how the seam feels. That still needs my own eyes, the same eyes that caught the double image in the first place.

Closing note

One thing worth stating plainly. The immersive build this wrapper lives in is not the version that ships. The client approved the standard build as the deliverable, and that is what went live, while the immersive one moved to a separate branch.