Skip to content

codeburn.app: logo marquee snaps ~half a viewport backwards every loop (translate(-50%) resolves against the container, not the strip) #887

Description

@ozymandiashh

Symptom

On https://codeburn.app, the "Supported tools, 36 and counting" logo marquee scrolls smoothly and then visibly snaps backwards once per animation cycle instead of looping seamlessly.

Measured from a 60fps screen recording (17.8s) by phase-correlating the logo band between consecutive frames:

  • steady state: constant drift of ~4 device px per 100ms (smooth scroll)
  • at one point in the cycle: a single-frame discontinuity of ~512 CSS px backwards, then smooth scrolling resumes

So this is not jank or a dropped frame; it is the loop restart landing in the wrong place.

Root cause

The marquee markup and CSS are:

<div class="relative w-full overflow-hidden py-2" style="mask-image: ...">
  <div class="flex gap-8 animate-scroll">
    <!-- 36 children: the 18 tools rendered twice, back to back -->
  </div>
</div>
.animate-scroll { animation: scroll 30s linear infinite }
@keyframes scroll { 0% { transform: translate(0) } to { transform: translate(-50%) } }

Two problems, one big and one small:

1. translate(-50%) resolves against the element's own width, and the element is viewport-wide, not content-wide. .animate-scroll is a plain block-level flex container, so its computed width equals its parent (the masked, overflow-hidden wrapper, ~1000 CSS px on a typical desktop), while the 36 shrink-0 children overflow it without growing it. -50% therefore translates by half the viewport, not half the strip (~3600 px of content). The measured ~512 px snap is exactly half the container width from the recording. The duplicate copy of the logos never lines up with where the animation resets, so every 30s the strip jumps by half a screen.

2. Even after fixing the width, gap-8 breaks the seam by half a gap. With gap, 36 items have 35 inter-item gaps. Half the true content width is 18 items + 17.5 gaps, but the second copy starts after 18 items + 18 gaps. The -50% point misses the duplicate's origin by gap/2 = 16 px, which shows up as a small residual jitter at every loop even once the element is max-content wide.

Fix shape

The standard seamless structure fixes both at once:

<div class="overflow-hidden" style="mask-image: ...">
  <div class="flex w-max animate-scroll">
    <div class="flex gap-8 pr-8 shrink-0"><!-- 18 logos --></div>
    <div class="flex gap-8 pr-8 shrink-0" aria-hidden="true"><!-- same 18 logos --></div>
  </div>
</div>
  • w-max (width: max-content) makes -50% mean half the actual strip;
  • two identical halves, each carrying its own trailing pr-8, make the halves exactly equal, so -50% lands precisely on the second copy's origin and the loop is invisible;
  • aria-hidden on the duplicate keeps the logo list read once by screen readers.

Worth pairing with a prefers-reduced-motion: reduce guard that pauses the animation, since this is a purely decorative marquee.

The two vertical marquees using scrollUp/scrollDown (translateY(-50%)) are worth checking for the same width assumption in the other axis; I only measured the horizontal one.

Happy to send the recording if useful.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions