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 clipon the strip,flex flex-none alignon each track, andflex gap-sm align nowrap leadon 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-durationand the spacing with--marquee-gap. - The edge fade is the
scroll-maskutility, not custom CSS. It normally reveals each edge as you scroll toward it, so setting--mask-color-startand--mask-color-endtotransparentholds both edges faded for a strip that moves on its own.--mask-sizesets 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 witharia-labelledby, so the whole block is one region a reader can find and skip rather than a loose row of logos.flowspaces 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: reducethe 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, useinerton the duplicate instead, since a focusable element insidearia-hiddenis 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.