Skip to content

Turning 113 Client Logos into a Pure CSS Marquee

Adityo Guni Waluyo

A 113-logo client grid pushed everything below the fold. I rebuilt it as a pure CSS two-row marquee with an exact -50 percent loop.

TL;DR

A 113-logo static grid buried the homepage content, so I replaced it with a pure CSS marquee instead of pulling in a JS library. Two duplicated tracks animate translate3d from 0 to -50%, with flex gap folded into padding so the loop seams perfectly. Transform-only animation, edge fades via mask-image, aria-hidden tiles, and prefers-reduced-motion keep it fast and accessible.

First deploy, I opened the homepage on my laptop and all I saw was one long white wall. A responsive grid holding 113 client logo tiles pushed the actual homepage content way down. Visitors had to scroll past an ocean of white cards before anything I actually wanted to show entered the screen.

My first instinct was to just drop in a popular marquee library, tweak a few options, done. But imagining the bundle size ballooning just to slide some static images around, I realized it was massive overkill.

The saner answer turned out to be a pure CSS logo marquee. I tore the static grid down and rebuilt it as two infinite tracks that run without a single line of JavaScript.

The loop trap and the CSS fix

The final structure uses two unordered lists. The first track holds the first 57 tiles, duplicated exactly twice. The second track holds the remaining 56 tiles with the same treatment. The count of unique tiles stays 113, just replicated for the looping.

The animation leans on @keyframes, which defines the waypoint styles of a CSS animation and binds them to an element [1]. The first track moves with translate3d from 0 to -50 percent. The second track runs the mirrored keyframes from -50 percent back to 0, so it reads as the opposite direction, all at 90 seconds linear infinite.

Here is the main trap. The -50 percent endpoint only loops seamlessly when the track holds exactly two identical copies and the inter-tile gap is folded into the jump point. I hit an animation that jumped roughly once per loop. The cause turned out to be a margin on the last item instead of folding the flex gap into an equivalent padding-right. Once I switched to padding, the loop landed exactly on the seam without a blink.

@media (prefers-reduced-motion: no-preference) {
  .marquee-track-1 {
    animation: scroll-left 90s linear infinite;
  }
  .marquee-track-2 {
    animation: scroll-right 90s linear infinite;
  }
}

@keyframes scroll-left {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}

@keyframes scroll-right {
  from { transform: translate3d(-50%, 0, 0); }
  to { transform: translate3d(0, 0, 0); }
}

The big win of this approach is performance. Animations restricted to transform and opacity stay on the compositing stage of the rendering path [4]. Properties that trigger layout or paint make animations slow, so left or margin must never join the party.

Accessibility and visual details

The container edges also needed attention. I added an edge fade using an inline mask-image with a linear gradient: transparent, black 8 percent, black 92 percent, transparent. This property accepts generated images including gradients and hides element regions per the mask alpha channel [3]. The result: logos appear and vanish smoothly at the edges instead of getting cut off abruptly.

On the accessibility side, every tile is marked a decorative list item with aria-hidden="true". The attribute removes purely decorative or duplicated content from the accessibility tree and must never sit on a focusable element [5]. These logos only exist to build visual credibility, so having a screen reader read them 113 times would wreck the experience.

The strip container itself also gets a presentational role, so screen readers don't have to guess at a list that is deliberately twice as long as it looks. The whole animation block is wrapped tightly in the prefers-reduced-motion: no-preference media query. This is not code decoration. Scaling or panning large objects are vestibular motion triggers, so ambient marquee motion belongs behind that user preference [2]. Users who tone motion down in their OS get a tidy static strip instead of an animation that forces itself on them.

Tile sizes are responsive, from 112 pixels on phones up to 144 on desktop, so about eight logos show per row without extra breakpoints.

I ended up deciding I will never again pull in a dependency just for a visual effect the browser can handle with a few lines of CSS. Sometimes the most elegant solution is the one closest to the machine, as long as you understand exactly how it works.

## Sources [1] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@keyframes [2] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion [3] https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/mask-image [4] https://web.dev/articles/animations-guide [5] https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden

Related articles