Fylgja Marquee

A marquee is a strip of content that scrolls past continuously, most often a row of brand logos under a line like “trusted by”. It is a list translated by a CSS animation, with a second copy behind it so the loop never shows a seam, and it stops while the pointer or the keyboard is inside it.

Preview

Trusted by

  • Aurora
  • Basalt
  • Cinder
  • Driftwood
  • Ember
  • Fathom
<section class="flow" aria-labelledby="brands-title" style="--flow: 1.25rem">
	<h2 id="brands-title" class="text-center" style="--h-size: 1rem">Trusted by</h2>
	<div class="marquee flex clip scroll-mask" style="--marquee-duration: 30s; --mask-size: 3rem; --mask-color-start: transparent; --mask-color-end: transparent">
		<ul role="list" class="flex flex-none align">
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#aurora" /></svg>Aurora</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#basalt" /></svg>Basalt</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#cinder" /></svg>Cinder</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#driftwood" /></svg>Driftwood</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#ember" /></svg>Ember</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor" aria-hidden="true"><use href="/images/placeholders/brands.svg#fathom" /></svg>Fathom</li>
		</ul>
		<ul role="list" class="flex flex-none align" aria-hidden="true">
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#aurora" /></svg>Aurora</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#basalt" /></svg>Basalt</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#cinder" /></svg>Cinder</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#driftwood" /></svg>Driftwood</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#ember" /></svg>Ember</li>
			<li class="flex gap-sm align nowrap lead"><svg width="22" height="22" fill="currentcolor"><use href="/images/placeholders/brands.svg#fathom" /></svg>Fathom</li>
		</ul>
	</div>
</section>

<style>
	.marquee {
		--marquee-gap: 3rem;
		--marquee-duration: 24s;
		gap: var(--marquee-gap);

		> * {
			gap: var(--marquee-gap);
			animation: marquee var(--marquee-duration) linear infinite;
		}

		&:where(:hover, :focus-within) > * {
			animation-play-state: paused;
		}
	}

	@keyframes marquee {
		to {
			translate: calc(-100% - var(--marquee-gap)) 0;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.marquee.clip {
			overflow-x: auto;
		}

		.marquee > * {
			animation: none;
		}

		.marquee > [aria-hidden="true"] {
			display: none;
		}
	}
</style>

Usage

  • Put the logos in a <ul role="list">, then repeat the whole list as a second <ul role="list" aria-hidden="true">. The copy is what makes the loop seamless.
  • Lay it out with utilities throughout: flex clip on the strip, flex flex-none align on each track, and flex gap-sm align nowrap lead on each item. The only CSS left is the gap, which has to match the distance the keyframes translate, and the animation itself.
  • Both tracks animate together, each translating one track width plus one gap, so the copy lands exactly where the original started.
  • Use <img> for the logos when they keep their own brand colors. A logo strip in full color is the normal case, and nothing here depends on the marks being monochrome.
  • This example uses inline SVG instead, so the marks take the page color. Keeping them in one sprite of <symbol id> elements and pulling each in with <svg fill="currentcolor"><use href="sprite.svg#id" /></svg> holds every item to two nodes rather than repeating the whole path, which counts twice over given the duplicate track.
  • Set the pace with --marquee-duration and the spacing with --marquee-gap.
  • The edge fade is the scroll-mask utility, not custom CSS. It normally reveals each edge as you scroll toward it, so setting --mask-color-start and --mask-color-end to transparent holds both edges faded for a strip that moves on its own. --mask-size sets how far the fade reaches.
  • The strip pauses on hover and on focus within, so a reader can stop it to read an item or reach anything inside it.
  • Wrap the heading and the strip in a <section> named with aria-labelledby, so the whole block is one region a reader can find and skip rather than a loose row of logos. flow spaces the two without a leading margin.
  • Keep the heading a real heading and set its visual size with --h-size. A logo cloud lead-in is small, but it still belongs in the page outline.

Anatomy

Part Responsibility
<section aria-labelledby> Names the block, so it is one region in the outline.
Heading The lead-in, sized down with --h-size.
.marquee The gap and the animation, the only custom CSS left.
.clip Keeps the second track from widening the page.
.scroll-mask Fades both edges, sized by --mask-size.
<ul role="list"> The logos, in one track that animates.
<ul aria-hidden="true"> The duplicate track that closes the loop.
<use href="…#id"> The logo mark, one sprite symbol per item. An <img> also fits.

Accessibility

  • With prefers-reduced-motion: reduce the animation is off, the duplicate is hidden, and the strip becomes a scrollable region instead. A reader who is affected by motion never sees it move.
  • The strip also stops while the pointer is over it or the keyboard is inside it, so a reader can hold it still to read an item.
  • A row of logos is passive, so that is enough. The moment anything inside the strip is interactive, add a real pause control, because a moving target is hard to hit and there is no hover on touch.
  • The duplicate track is aria-hidden="true" so the content is announced once. If the logos are links, use inert on the duplicate instead, since a focusable element inside aria-hidden is still reachable by keyboard.
  • Keep the speed slow enough to read. Text that outruns the reader is decoration, not content.

Variants

A ticker and a logo wall are the same component with different values. --marquee-duration sets the pace and --marquee-gap the space between items. The edges come from scroll-mask, so --mask-size sets how far they fade; drop the class for a hard edge.

Examples

For a strip the reader drives rather than one that moves on its own, use the Carousel, which snaps and needs no animation at all.

FAQ

Why are the items duplicated?

The loop works by translating the track exactly one track width plus one gap, so the second copy has already taken the first one's place when the animation restarts. Without the copy there would be a visible gap at the end of every cycle.

Why does the copy have aria-hidden?

It is the same content twice, and a screen reader should read it once. The copy exists only so the loop looks seamless, so it is hidden from assistive tech.

Does the marquee need a pause control?

Not for a row of plain logos. It never moves for a reader who has reduced motion turned on, and there is nothing in it to act on. Add a real control as soon as the items become interactive, a link or a button, since a moving target is hard to hit and there is no hover on touch. A checkbox the CSS reads with :has is enough, no script needed.

What if the logos are links?

Put inert on the duplicate track instead of aria-hidden. A focusable element inside an aria-hidden subtree is still reachable by keyboard, which is a real failure. inert removes the copy from both the tab order and the accessibility tree.

Should the logos be a sprite or an img?

Either. An img is the straightforward choice when the logos keep their own brand colors, which most real logo strips do. The sprite earns its place when you want inline SVG, because pulling a symbol in with use keeps each item to two nodes instead of repeating the whole path, and the duplicate track means every logo is in the markup twice. Taking the page color through currentcolor is a bonus of inline SVG, not the reason to reach for it.

Does the marquee need JavaScript?

No. The scroll is a CSS animation and the pause is a hover and focus rule. Nothing here runs script.

What happens with reduced motion?

The animation is switched off, the duplicate copy is hidden, and the strip becomes horizontally scrollable instead. The content stays reachable, it just does not move on its own. That rule is written as .marquee.clip so it outweighs the clip utility whichever order the two stylesheets load in.

How do I change the speed?

Set --marquee-duration. It is the time for one full pass, so a longer value is slower. Adding items makes the track longer, so raise the duration to keep the same apparent speed.

Should I use the old marquee element?

No. The HTML marquee element is obsolete, was never keyboard or screen reader friendly, and has no way to stop. This builds the same effect from a list and a CSS animation.