D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Section-to-Section Gradient Banding and an Obvious Fake Scrim? A Scrim on Top of the Photo, 4 Linear Stops, and No Colour Checkpoint

· · 9 min read
Section-to-Section Gradient Banding and an Obvious Fake Scrim? A Scrim on Top of the Photo, 4 Linear Stops, and No Colour Checkpoint

Some failures never reach the console and never turn a build red. The page runs, the animation runs, nothing throws. But the moment you scroll from one section into the next, your eye knows something is off. The transition looks like a sheet of transparent black slapped over the image, with faint horizontal steps running through the gradation. Not a technical bug, still a bug.

I hit this on a company profile site built around an immersive scroll. The idea was simple: the hero landscape should dissolve into the next section with no perceptible border. I even had a reference, one page whose transition did exactly that and did it perfectly. I copied the approach. Mine banded. Theirs did not.

The symptom: two defects I mistook for one

Zoom into a screenshot of my transition and there are two separate faults sitting on top of each other.

First, banding. The fade from light to dark was not smooth. Horizontal bands showed up like stair treads, worst through the middle, and much more obvious on high contrast displays.

Second, and far more damaging, the transition read as a layer. Nobody looking at it thought "the sky is getting darker". They thought "there is a transparent black box over the photo". Exactly like a theatre scrim someone forgot to light properly.

For a while I treated both as one illness and hunted for one cure. Wrong. The first is about stop count, the second is about layer order, and there was a third illness I had not even noticed yet.

Reading the working version line by line

Since guessing was getting me nowhere, I stopped tweaking values and opened the reference page's CSS, reading it line by line the way you read a diff. It took about twenty minutes and three differences fell out.

One: my scrim sat ON TOP of the photo, theirs was the backdrop itself. Mine was a semi transparent linear-gradient stacked over the image. Theirs was a block whose background genuinely was an opaque gradient, with the photo living inside it as texture. Different stacking, different result. One looks like a filter, the other looks like a landscape.

Two: I used three or four stops, they used sixteen. And not sixteen stops spread evenly by alpha value, but sixteen stops along an eased alpha curve.

Three: they never joined photo to photo at all. This was the most important finding and the one I least expected. The first block fades to an exact solid colour. The next block opens from that same exact solid. What meets at the seam is not two images, it is two identical fields of colour. A seam cannot show when both sides of it are the same colour.

Why four linear stops will always band

When you write a two stop gradient from transparent to solid, the browser interpolates everything in between. That interpolation is linear in colour space, and human vision is not. Down in the low alpha range, small changes feel large. Up in the high alpha range, large changes are almost imperceptible. So the visual change gets distributed unevenly, and the quantised jumps between levels surface as bands.

The fix is not simply adding more stops. The fix is packing stops tighter at the start, where your eye is most sensitive, then letting them spread out. This is the alpha curve I lifted from the reference page:

0, .01, .03, .07, .12, .18, .25, .33, .41, .50, .59, .67, .76, .85, .93, 1

Look at the gaps near the start: 0.01, then 0.02, then 0.04. Painfully slow. Only around the middle do they widen to 0.08 and 0.09. That curve is what makes the beginning of the fade feel like nothing is happening at all, before it darkens with an acceleration that reads as natural.

Poured into CSS, sixteen stops spread evenly across a 75vh height:

.hero-grad-over {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px; /* not 0 */
  height: 75vh;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgba(1, 90, 169, 0) 0%,
    rgba(1, 90, 169, 0.01) 6.66%,
    rgba(1, 90, 169, 0.03) 13.33%,
    rgba(1, 90, 169, 0.07) 20%,
    rgba(1, 90, 169, 0.12) 26.66%,
    rgba(1, 90, 169, 0.18) 33.33%,
    rgba(1, 90, 169, 0.25) 40%,
    rgba(1, 90, 169, 0.33) 46.66%,
    rgba(1, 90, 169, 0.41) 53.33%,
    rgba(1, 90, 169, 0.5) 60%,
    rgba(1, 90, 169, 0.59) 66.66%,
    rgba(1, 90, 169, 0.67) 73.33%,
    rgba(1, 90, 169, 0.76) 80%,
    rgba(1, 90, 169, 0.85) 86.66%,
    rgba(1, 90, 169, 0.93) 93.33%,
    rgba(1, 90, 169, 1) 100%
  );
}

Two details in that block are easy to skim past.

The last stop is solid, not 0.95 and not 0.98. Stop at 0.98 and a sliver of photo always bleeds through, which is more than enough to wreck the checkpoint in the next part.

And it is bottom: -1px, not bottom: 0. On displays with a fractional device pixel ratio, the computed element height can land on a half pixel, and you get a bright hairline along the bottom edge. Nudging it one pixel down kills that subpixel gap with no side effects, because the bottom pixel is already solid colour.

A scrim sitting on top of a photo will never blend

Fixing the stops killed the banding, but the transition still read as something pasted on. That is a stacking problem, and the fix is to invert how you think about it.

What I had: photo as the background, gradient as an overlay above it.

What works: the gradient IS the background, and the photo is only a texture inside it. The next block opens with a fully opaque gradient, and the sky photo inside it gets its opacity pulled down until it stops being the subject and becomes texture.

.about-block {
  position: relative;
  background: linear-gradient(
    to bottom,
    #015aa9 0%,
    #8fc2d7 50%,
    #2b2b2b 100%
  );
}
 
.about-block__texture {
  position: absolute;
  inset: 0;
  opacity: 0.75;
  object-fit: fill; /* not cover */
}

object-fit: fill looks wrong if you have internalised the rule that cover is the safe default. But once the photo is demoted to texture, distorting its aspect ratio genuinely does not matter, and what matters far more is that the gradient behind it must not leak on any edge. cover crops, contain leaves gaps, fill guarantees full coverage so the colour you see at the top and bottom of that block is exactly the colour you wrote.

The result: the topmost pixel of this block is #015aa9 softened by texture, not a photo that happens to look blueish.

The part I underestimated: an exact colour checkpoint

By this point each block looked good on its own and the join still missed. Because I was still carrying one wrong assumption: I thought the job was to blend block one into block two.

It is not. The job is to force both of them to meet at the same colour.

The first block ends solid #015aa9. The second block starts at #015aa9. The join cannot fail, because visually there is nothing being joined. Solid colour meets identical solid colour. Whatever the display calibration, however the browser rounds element heights, you still get one continuous field of colour.

This is also why the photo to photo approach is fundamentally fragile. Two photos will never line up perfectly at a boundary unless they were one image that got cut. Solid colours always line up.

The geometry needs a checkpoint too

The same principle applies to the scroll numbers, and this is what saved me while the hero block was still animating.

My hero block is 200vh tall, and inside it a layer drifts from y: 0 to y: 100vh across that block. So the end of that drift lands at scroll position 300vh. And 300vh has to be exactly where the next block's solid colour begins.

gsap.to(".hero-layer", {
  y: () => window.innerHeight, // 0 -> 100vh
  ease: "none",
  scrollTrigger: {
    trigger: ".hero-block", // 200vh tall
    start: "top top",
    end: "bottom top",
    scrub: true,
  },
});

The arithmetic is trivial but it has to be written down and checked: block height plus drift distance must equal the start of the next solid. Change one of them later, say the hero block grows to 240vh because the copy got longer, and the join drifts off again with symptoms that look exactly like a gradient problem, even though your gradient is fine. I left a comment above both numbers so future me does not repeat the round trip.

When you need a genuinely pixel-real seam

One part of that site could not be solved with the solid colour trick, because it had to read as a single unbroken landscape running the entire length of the scroll.

The answer was not CSS. The answer was to stop using two images. I baked one full vertical panorama outside the browser with ffmpeg, using an alpha ramp so the bottom of the upper image dissolves into the one below it before they get composited:

ffmpeg -i top.png -vf \
  "format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*(1-Y/H)'" \
  top-ramp.png

Once those two images are a single file there is no join left to miss, because the join was finished at render time instead of at runtime.

The panorama layer gets a height of max(107vw, 135svh) so it is always taller than the viewport at any aspect ratio, then it pans across the whole arrival of the section:

const layerHeight = layer.offsetHeight;
const vh = window.innerHeight;
 
gsap.fromTo(
  layer,
  { yPercent: 102 },
  {
    yPercent: -(1 - vh / layerHeight) * 100,
    ease: "none",
    scrollTrigger: {
      trigger: stage,
      start: "top bottom",
      end: () => stage.offsetHeight - vh,
      scrub: true,
    },
  }
);

That end value is not -100, and that is not a matter of taste. yPercent: -100 shifts the layer by its own full height, which means the bottom of the panorama travels past too and you lose the last viewport of image. The formula -(1 - vh / layerHeight) * 100 stops exactly when the bottom edge of the panorama aligns with the bottom edge of the viewport. Precisely one full screen of image left visible, no more and no less.

What I took away