CSS Marquee Text
Infinite scrolling text using CSS translateX and @keyframes, the modern replacement for the obsolete marquee tag. Content is duplicated to hide the seam.
- Level
- Beginner
- Published
- Updated
- Reading time
- 7 min
Try it
3 live demos
Two tracks, opposite directions
Other ways to build it
Vertical ticker
See the codeFaded edges, and speed as a variable
See the codeBuild it
6 snippets to copy
CSS Marquee Text
HTML
<div class="marquee-stack">
<div class="marquee-track">
<div class="marquee-inner">
<!-- first half -->
<span class="marquee-item">Pure CSS</span>
<span class="marquee-item">Zero JavaScript</span>
<span class="marquee-item marquee-item-accent">No Dependencies</span>
<span class="marquee-item">Modern CSS</span>
<span class="marquee-item">Open Source</span>
<span class="marquee-item marquee-item-accent">Built with Eleventy</span>
<!-- exact duplicate, so the reset at -50% is invisible -->
<span class="marquee-item" aria-hidden="true">Pure CSS</span>
<span class="marquee-item" aria-hidden="true">Zero JavaScript</span>
<span class="marquee-item marquee-item-accent" aria-hidden="true">No Dependencies</span>
<span class="marquee-item" aria-hidden="true">Modern CSS</span>
<span class="marquee-item" aria-hidden="true">Open Source</span>
<span class="marquee-item marquee-item-accent" aria-hidden="true">Built with Eleventy</span>
</div>
</div>
<div class="marquee-track">
<div class="marquee-inner marquee-inner-rev">
<span class="marquee-item">Scroll Snap</span>
<span class="marquee-item marquee-item-accent">CSS Nesting</span>
<span class="marquee-item">Custom Properties</span>
<span class="marquee-item">Scroll-Driven</span>
<span class="marquee-item marquee-item-accent">Container Queries</span>
<span class="marquee-item">Cascade Layers</span>
<span class="marquee-item" aria-hidden="true">Scroll Snap</span>
<span class="marquee-item marquee-item-accent" aria-hidden="true">CSS Nesting</span>
<span class="marquee-item" aria-hidden="true">Custom Properties</span>
<span class="marquee-item" aria-hidden="true">Scroll-Driven</span>
<span class="marquee-item marquee-item-accent" aria-hidden="true">Container Queries</span>
<span class="marquee-item" aria-hidden="true">Cascade Layers</span>
</div>
</div>
</div>
CSS
.marquee-stack {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
overflow: hidden;
}
.marquee-track {
overflow: hidden;
padding: .7rem 0;
border-top: 1px solid #2a2a2d;
border-bottom: 1px solid #2a2a2d;
}
.marquee-inner {
display: inline-flex;
white-space: nowrap;
animation: marquee-scroll 16s linear infinite;
}
.marquee-inner:hover {
animation-play-state: paused;
}
/* second row travels the other way, a little faster */
.marquee-inner-rev {
animation-direction: reverse;
animation-duration: 12s;
}
.marquee-item {
display: inline-flex;
align-items: center;
gap: 2rem;
padding: 0 2rem;
color: #88888f;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .75rem;
letter-spacing: .08em;
text-transform: uppercase;
}
.marquee-item::after {
content: "✦";
color: #b8ff57;
font-size: .6rem;
}
.marquee-item-accent {
color: #b8ff57;
}
@keyframes marquee-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
Vertical ticker
The same duplication rule on the other axis. The track gets a fixed height rather than borrowing its width from the page, the inner element stacks its items in a column, and the keyframes move to translateY. white-space: nowrap comes off, because the items are no longer sharing a line. Everything else, including the duplicated list and the -50%, is unchanged.
HTML
<div class="marquee-vtrack">
<div class="marquee-vinner">
<span class="marquee-vitem">BUILD 4192 <b>passed</b></span>
<span class="marquee-vitem">BUILD 4191 <b>passed</b></span>
<span class="marquee-vitem">BUILD 4190 <i>failed</i></span>
<span class="marquee-vitem">BUILD 4189 <b>passed</b></span>
<span class="marquee-vitem">BUILD 4192 <b>passed</b></span>
<span class="marquee-vitem">BUILD 4191 <b>passed</b></span>
<span class="marquee-vitem">BUILD 4190 <i>failed</i></span>
<span class="marquee-vitem">BUILD 4189 <b>passed</b></span>
</div>
</div>
CSS
.marquee-vtrack {
height: 120px; /* the clip window is now a height */
overflow: hidden;
border: 1px solid #2a2a2d;
border-radius: 12px;
width: 260px;
}
.marquee-vinner {
display: flex;
flex-direction: column;
animation: marquee-scroll-y 9s linear infinite;
}
.marquee-vtrack:hover .marquee-vinner {
animation-play-state: paused;
}
.marquee-vitem {
padding: .55rem .9rem;
font-family: "DM Mono", "Fira Code", Consolas, monospace;
font-size: .72rem;
color: #88888f;
border-bottom: 1px solid #2a2a2d;
}
/* a separate keyframe name, so the horizontal rule is untouched */
@keyframes marquee-scroll-y {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
Faded edges, and speed as a variable
overflow: hidden cuts every item off mid letter at the track boundary. A mask-image running from transparent to opaque and back fades both ends instead, so items arrive and leave rather than appearing at a hard line. The duration is read from a custom property on the track, which is how two rows holding different numbers of items can be made to move at the same speed.
HTML
<!-- 4 items, so a shorter distance and a shorter time -->
<div class="marquee-track marquee-track-fade" style="--marquee-time: 14s">
<div class="marquee-inner">...</div>
</div>
<!-- 6 items, so proportionally longer -->
<div class="marquee-track marquee-track-fade" style="--marquee-time: 24s">
<div class="marquee-inner">...</div>
</div>
CSS
.marquee-track-fade {
/* transparent at both ends, opaque across the middle 80% */
-webkit-mask-image: linear-gradient(90deg,
transparent, #000 10%, #000 90%, transparent);
mask-image: linear-gradient(90deg,
transparent, #000 10%, #000 90%, transparent);
}
/* duration comes from the track, so item count and time stay in step */
.marquee-track-fade .marquee-inner {
animation-duration: var(--marquee-time, 16s);
}
/* WCAG 2.2.2: motion that starts on its own needs a way to stop it */
@media (prefers-reduced-motion: reduce) {
.marquee-inner {
animation: none;
}
.marquee-track {
overflow-x: auto;
}
}
Understand it
How it works and 7 properties
A CSS marquee scrolls a strip of content sideways forever using one @keyframes rule and a translateX transform. The trick that makes the loop invisible is duplication: the track holds the same list twice, and the animation travels exactly -50%, so the second copy arrives where the first began at the moment the transform resets. The HTML <marquee> element it replaces was removed from the standard years ago.
Marquees carry logo strips, ticker tape, and running status lines. This page has the two horizontal tracks below, a vertical ticker built on translateY, and a masked version whose content fades out at both edges instead of being cut off by overflow: hidden.
How it works
The loop requires the content to be duplicated inside .marquee-inner. The animation moves from translateX(0) to translateX(-50%), and at that halfway point the second copy has slid into where the first started, so the jump back to 0 is invisible. animation-play-state: paused on :hover stops the scroll so a reader can hold still on an item.
Three boxes are involved. The outer .marquee-track clips, using overflow: hidden, and it is the only element with a width tied to the page. Inside it, .marquee-inner is display: inline-flex with white-space: nowrap, which lets it grow past its parent to the full width of its contents rather than wrapping to fit. That overflow is the thing being animated. The items themselves need no positioning at all.
The -50% is not a stylistic choice, it is arithmetic. A percentage in translateX resolves against the width of the transformed element, so -50% moves the track left by exactly half its own width. Half of two identical copies is one copy. Put an odd number of items in, or forget to duplicate the list, and the strip visibly jumps at the moment the animation restarts.
Speed is the part that surprises people. animation-duration sets how long one full traversal takes, not how fast the text moves, so adding items to a track makes it scroll faster while the duration stays put. Two tracks with different item counts and the same duration will visibly run at different speeds. The fix is to scale the duration with content length, which is what the custom property in the masked variant below does.
Animating transform keeps the work on the compositor. The same effect written with left, margin-left or background-position forces layout or paint on every frame, and a long strip of text is expensive to lay out. Because a transform never affects layout, the track also never pushes the page around while it moves.
animation-play-state: paused on :hover is what makes the content readable, and it belongs on the animated element or an ancestor of it. Putting it on a child does nothing. Pausing is also the accessibility requirement rather than a nicety, since WCAG asks for a mechanism to stop any motion that starts on its own and runs longer than five seconds. Motion that responds to the reader instead of a clock is covered in scroll-triggered reveal, where progress comes from the scroll position.
Accessibility notes
Automatic horizontal motion is a genuine barrier, not a matter of taste. WCAG 2.2.2 requires a way to pause, stop or hide any moving content that starts automatically and lasts more than five seconds. Hover to pause satisfies that for a mouse and fails for everyone else, so pair it with :focus-within if the strip contains links, or with a real checkbox control if it does not.
Wrap the pattern in a prefers-reduced-motion block and stop the animation outright for readers who ask for less motion. Scrolling text is a common trigger for vestibular symptoms and for anyone tracking words with difficulty. A static overflowing strip is a poor substitute, so when motion is off, let the track scroll normally with overflow-x: auto instead of freezing content out of view.
Duplicating the list means every item appears twice in the accessibility tree. Mark the second copy aria-hidden="true" so only one pass is exposed. If the strip is purely decorative, such as a band of logos with no links, hide the whole thing from assistive technology and give the section a normal heading instead.
Mistakes worth avoiding
- Forgetting to duplicate the list. The strip scrolls off, the transform resets to zero, and the whole thing visibly snaps back with a gap.
- Animating to a pixel value instead of
-50%. It works at one content length and breaks the moment an item is added, because the reset point no longer lines up with a copy boundary. - Using an eased timing function.
ease-in-outslows the strip at each end of the cycle, and since the cycle end is the seam, the pause is exactly where it is most visible. - Leaving
overflow: hiddenoff the track. The inner element is wider than the page, so the document gains a horizontal scrollbar and the layout shifts sideways. - Giving every row the same duration regardless of content. A row with twice the items moves at twice the speed, which reads as a rendering bug rather than a design decision.
Ship it
5 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
animation | 4 | 5 | 5.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
mask-image | 120 | 53 | 15.4 | 120 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use for CSS Animation, CSS 2D Transforms, Flexible Box Layout, CSS Masks and the prefers-reduced-motion media query. The core marquee needs nothing newer than 2012. Masks are the one recent piece: Can I Use records Chrome before 120 and Safari before 15.4 as supporting only the -webkit-mask-image form, so ship both declarations if the edge fade matters on older devices. The <marquee> element still renders in every browser but it is obsolete in the HTML standard and should not be used in new work.
What you can build with it
- Logo strips. A row of customer or sponsor marks running slowly under a heading, where the point is breadth rather than any one logo.
- Status and ticker lines. Prices, scores or build results across the top of a dashboard, where the list is longer than the space available.
- Notice bars. A shipping deadline or a sale window at the top of a shop, repeated so it is readable no matter when the page loaded.
- Editorial dividers. A band of section keywords between long articles, used the way a printed rule would be used.
- Event lineups. Names of speakers or acts on a conference page, which stays readable at any viewport width because the strip never wraps.
Frequently asked questions
Why does my CSS marquee jump when it loops?
translateX(-50%) moves the element by half of its own width, so the reset only lines up when the second half matches the first. Duplicate the list item for item, including any separators, and the seam disappears.How do I pause a CSS marquee on hover?
animation-play-state: paused in a :hover rule on the animated element or one of its ancestors. It freezes the current frame and resumes from there. Removing the animation property instead would snap the strip back to its start position.Should I still use the HTML marquee tag?
<marquee> tag is obsolete, even though browsers still render it. A @keyframes rule with a transform gives finer control over speed and direction, runs on the compositor, and can be paused or disabled with a media query.How do I make the marquee scroll vertically?
translateX for translateY in the keyframes, give the track a fixed height instead of relying on its width, and drop white-space: nowrap since the items now stack. The vertical variant on this page uses a separate keyframe name so the horizontal rule is left untouched.Why do two marquee rows move at different speeds?
animation-duration fixes the time for one full traversal, so a longer strip has to cover more distance in the same seconds. Set the duration from a custom property that scales with the item count, or measure one row and set the others from it.