D
P
0
← All articles Baca dalam Bahasa Indonesia

JavaScript, DOM & Browser Animation

Last Card Cropped at the End of a Horizontal Scroll Lock? `scrollWidth - clientWidth` Drops the Right Padding

· · 9 min read
Last Card Cropped at the End of a Horizontal Scroll Lock? `scrollWidth - clientWidth` Drops the Right Padding

This bug threw nothing. No error, no warning, no red numbers anywhere. Just one card that kept stopping in the wrong place, roughly a hand's width too far right, every single time the animation finished.

The context was a long product explainer section in an online store, built in the style of a spec page: the section pins to the screen, then three cards pan sideways as the visitor scrolls vertically. Cards 01 and 02 glided past. Card 03 never fully arrived.

The symptom: card 03 jams against the clip line

At the end of the pan, the right edge of card 03 landed exactly on the clipping boundary. Its rounded corner was shaved off, its shadow disappeared, and the whole thing looked sliced. The two cards before it had generous breathing room at the screen edge. The last one got none.

The structure was as plain as it gets:

<section class="hscroll" data-hscroll>
  <div class="hscroll__track" data-hscroll-track>
    <article class="hscroll__card">01</article>
    <article class="hscroll__card">02</article>
    <article class="hscroll__card">03</article>
  </div>
</section>
[data-hscroll] {
  overflow: hidden;
}
 
.hscroll__track {
  display: flex;
  gap: 32px;
  padding-inline: 6vw; /* breathing room left and right */
  will-change: transform;
}
 
.hscroll__card {
  flex: 0 0 520px;
  border-radius: 24px;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.12);
}

And the JavaScript used the formula that shows up in nearly every horizontal scroll tutorial:

const distance = track.scrollWidth - track.clientWidth;
 
function onScroll(progress) {
  track.style.transform = `translate3d(${-distance * progress}px, 0, 0)`;
}

It reads as obvious. scrollWidth is how wide the content is, clientWidth is how much of it you can see, the difference is how far you need to travel. That formula is correct for a container the browser actually scrolls. For a track you move yourself with transform, it lies.

The first suspect, and it was wrong

My first guess was that the section was too short, so the pan ran out before the last card had time to arrive. I raised the pinned height from 300vh to 500vh.

The result: the pan got slower, and card 03 still stopped at exactly the same spot.

That is actually a useful diagnostic, and I would reach for it first any time a scroll lock ends in the wrong place. If you lengthen the scroll area and the end point does not move by a single pixel, the problem is not in how scroll maps to progress. Progress is reaching 1 just fine. What is wrong is the number progress gets multiplied by. The ceiling lives in distance.

So I stopped touching the pin and started printing numbers.

console.log({
  scrollWidth: track.scrollWidth,
  clientWidth: track.clientWidth,
  distance: track.scrollWidth - track.clientWidth,
  lastRight: track.lastElementChild.getBoundingClientRect().right,
});

On a 1440px screen:

scrollWidth: 1710
clientWidth: 1440
distance:    270
lastRight:   1710.4

Three 520px cards plus two 32px gaps is 1624px of content. The left padding of 6vw is 86.4px. So the right edge of the last card sits at 1710.4px, which matches lastRight exactly. But scrollWidth reports 1710. The right padding, also 86.4px wide, is not counted anywhere.

With a distance of 270, the right edge of card 03 stops at 1710.4 minus 270, which is 1440.4. Dead on the clip line, a fraction of a pixel past it. That is the entire symptom explained.

The root cause: two properties that disagree about padding

The two properties I was subtracting turned out to measure different boxes.

clientWidth measures the padding box. It counts the content width plus the left padding plus the right padding. Both paddings are in.

scrollWidth measures the scrollable overflow area. It starts at the left edge of the padding box, so the left padding is in. But once the content overflows, Chromium and WebKit stop at the right edge of the last child. The container's right padding is not treated as part of that area.

This is the same old behavior that makes people wonder why the padding-right of an overflow-x: auto container seems to vanish once you scroll it all the way. Identical mechanism, except here the consequence is not a strange scrollbar, it is an animation that stops too early.

So the difference between those two numbers is short by exactly padding-right, always, systematically. At 1440px it is short by 86px. At 1920px it is short by 115px, because the padding is in vw. The 86px I was chasing was only the number on my own display. On another machine the same code shaves off a thicker or thinner slice with not a single line changed.

A small bonus from that console.log: scrollWidth returns an integer while the real geometry is 1710.4. Half a pixel is never something you will spot on its own, but it is extra evidence that this property is not a precision instrument for this kind of job.

The fix: measure the last card's edge, not the difference of two properties

The obvious temptation is to patch it: distance + paddingRight, done. I did not want that, because the patch depends on a browser quirk behaving the same way forever. If some engine one day starts including the right padding in scrollWidth, the patch flips into an 86px overshoot, and the symptom becomes a gaping hole at the end of the pan.

The durable move is to stop asking "how wide is the content" and start asking "where is the right edge of the last card right now". getBoundingClientRect() answers the second question, reading real rendered geometry, fractional pixels included.

One catch: getBoundingClientRect() is transform aware. Call it while the track is already halfway through its pan and you get the shifted position, not the natural one. And a re-measure most often happens exactly then, for example when a visitor rotates their phone or drags the window while sitting inside the section.

So the trick is to zero the transform for a moment, measure, and put it back.

const viewport = document.querySelector('[data-hscroll]');
const track = viewport.querySelector('[data-hscroll-track]');
 
function measureDistance() {
  const saved = track.style.transform;
 
  // 1. return to the natural position
  track.style.transform = 'translate3d(0, 0, 0)';
 
  // 2. force layout to flush before measuring
  void track.offsetWidth;
 
  // 3. read the real geometry
  const last = track.lastElementChild;
  const end = last.getBoundingClientRect().right;
  const edge = viewport.getBoundingClientRect().right;
  const padRight = parseFloat(getComputedStyle(track).paddingRight) || 0;
 
  // 4. restore exactly what was there
  track.style.transform = saved;
 
  return Math.max(0, end - edge + padRight);
}
 
let distance = measureDistance();

A few lines in there look trivial and each one is holding back a failure.

The reset and the restore happen inside the same synchronous task. The browser never paints between them, so there is no flicker for the visitor to see. The track jumps to zero and back without ever reaching the screen.

void track.offsetWidth forces the flush. Reading a layout property makes the browser apply any queued style change before continuing. Strictly speaking getBoundingClientRect() forces the same flush, so this line is redundant. I keep it because it states the intent out loud. Code like this gets refactored easily: the measurement moves into a helper, the result gets cached, the ordering shifts a little. The moment the ordering shifts, you are measuring the shifted position, and distance ends up wrong by however far the track happened to be panned. That is a far more painful bug to chase than one line that costs practically nothing.

The right edge comes from the viewport, not from window.innerWidth. While the section is full bleed the two numbers agree. The day someone drops this section into a max-width container, innerWidth becomes wrong and getBoundingClientRect().right stays right.

The right padding is read from computed style. Its value is 6vw, so hardcoding a number in JavaScript would just relocate the old bug. Let CSS remain the single source of truth about spacing.

After that, keep the number from going stale:

let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => {
    distance = measureDistance();
  }, 200);
});
 
window.addEventListener('load', () => {
  distance = measureDistance();
});

The measurement repeats after load because the cards contain images. Before those images finish loading, card widths may not be final, and a measurement taken too early produces a distance that is, once again, too short.

Debouncing the resize handler is not decoration either. Every measureDistance call forces one synchronous layout pass, and resize can fire dozens of times inside a single drag. Waiting until the drag settles makes that expensive read happen once instead of once per pixel.

With this in place, card 03 stops at 1354px, leaving an 86px gap to the right edge, exactly matching the gap card 01 has on the left at the start of the pan. The symmetry the designer intended all along.

Epilogue: the best fix turned out to be deleting the feature

A few days later the client saw the corrected version and said the scroll lock itself felt intrusive on desktop. It felt like their scrolling had been taken away from them, and three cards holding a few short paragraphs did not justify that feeling.

The section was replaced with a plain three column grid. All of its JavaScript was deleted, including the measuring function I had just fixed.

I do not count those hours as wasted. Two things came home with me. First, the measuring technique itself is still in use elsewhere, in places that genuinely need to move sideways. Second, the more bitter lesson: before you spend time perfecting an interaction, it pays to confirm that the interaction is wanted at all.

What I took away