CSS Marquee Text
Infinite scrolling text using CSS translateX and @keyframes, the modern replacement for the obsolete <marquee> tag.
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.
Two tracks, opposite directions
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">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>
</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">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>
</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%); }
}
Other ways to build it
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;
}
}
How it works
The seamless 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.
CSS properties used
animation- Runs the keyframe rule forever with
lineartiming. Anything eased will visibly slow at the loop point, because the reset lands mid ease. transformtranslateX(-50%)shifts the track by half its own width, which is one full copy of a duplicated list.overflowhiddenon the track is what turns an over wide strip into a window. Without it the inner element simply spills across the page.white-spacenowrapstops the items wrapping to the track width, so the inner element keeps its natural single line width.animation-play-statepausedfreezes the animation in place on:hoverand resumes it from the same frame, unlike removing the animation, which snaps back to the start.animation-directionreverseplays the same keyframes backwards, which is how the second row travels the other way without a second@keyframesblock.mask-image- A gradient to
transparentat each end fades the strip out instead of cutting it at the clip edge.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
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.
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 a screen reader announces every item twice. 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.
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.
- Announcement 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.
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.
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> was removed from the HTML standard and is listed as 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.