CSS Text Ticker
Words rotate vertically in a loop using CSS @keyframes and overflow: hidden, with no JavaScript.
A CSS text ticker swaps one word for the next inside a fixed height slot, using overflow: hidden to show exactly one line at a time and a translateY animation to move the rest into view. The words are a plain stack of elements in the markup, so they are real text a search engine can index and a reader can select. No script, no timers.
Two lines run below, each cycling four words. Two more builds follow: a hard cut version that swaps words instantly with steps() instead of sliding, and a continuous roll through a three line window with the edges faded out by a mask.
Words rotating on a loop
HTML
<div class="ticker-line">
<span class="ticker-static">CSS is</span>
<span class="ticker-slot">
<div class="ticker-inner">
<span class="ticker-word">powerful.</span>
<span class="ticker-word">modern.</span>
<span class="ticker-word">expressive.</span>
<span class="ticker-word">fast.</span>
<!-- duplicate first word for seamless loop -->
<span class="ticker-word">powerful.</span>
</div>
</span>
</div>
CSS
/* The line must be flex, or .ticker-slot stays inline
and its height plus overflow clipping never applies. */
.ticker-line {
display: flex;
align-items: center;
gap: .45em;
font-size: 1.6rem;
font-weight: 700;
color: #f0f0f0;
letter-spacing: -.02em;
}
.ticker-static {
color: #88888f;
font-weight: 400;
}
.ticker-slot {
height: 1.15em; /* exactly one word tall */
overflow: hidden;
position: relative;
}
.ticker-inner {
display: flex;
flex-direction: column;
animation: ticker-up 4s ease-in-out infinite;
}
.ticker-word {
height: 1.15em;
display: flex;
align-items: center;
line-height: 1;
white-space: nowrap;
color: #b8ff57;
font-family: "Instrument Serif", Georgia, serif;
font-style: italic;
font-weight: 400;
}
@keyframes ticker-up {
0% { transform: translateY(0); }
18% { transform: translateY(0); }
25% { transform: translateY(-1.15em); }
43% { transform: translateY(-1.15em); }
50% { transform: translateY(-2.3em); }
68% { transform: translateY(-2.3em); }
75% { transform: translateY(-3.45em); }
93% { transform: translateY(-3.45em); }
100% { transform: translateY(-4.6em); }
}
Other ways to build it
Hard cuts with steps()
Evenly spaced keyframes and steps(1, end) turn the slide into a swap. A one step function holds its starting value for the whole segment and jumps at the very end, so each word sits still for a quarter of the cycle and then is replaced in a single frame. There is no movement to watch, which suits a monospaced or terminal aesthetic and reads as faster than it really is. It also removes the alignment risk entirely: the column is only ever at an exact multiple of the word height, so a slot that is a fraction of a pixel out never shows two words at once.
HTML
<div class="ticker-stack">
<div class="ticker-line tk-cut-line">
<span class="ticker-static">Status:</span>
<span class="ticker-slot">
<div class="tk-cut-inner">
<span class="ticker-word tk-cut-word">resolving</span>
<span class="ticker-word tk-cut-word">fetching</span>
<span class="ticker-word tk-cut-word">building</span>
<span class="ticker-word tk-cut-word">deploying</span>
<span class="ticker-word tk-cut-word">resolving</span>
</div>
</span>
</div>
</div>
CSS
.tk-cut-inner {
display: flex;
flex-direction: column;
/* steps(1, end) holds each value for the whole segment and jumps
at the end of it, so the word swaps in one frame */
animation: ticker-cut 4.8s steps(1, end) infinite;
}
/* evenly spaced, because nothing is being eased between them */
@keyframes ticker-cut {
0% { transform: translateY(0); }
25% { transform: translateY(-1.15em); }
50% { transform: translateY(-2.3em); }
75% { transform: translateY(-3.45em); }
100% { transform: translateY(-4.6em); }
}
@media (prefers-reduced-motion: reduce) {
.tk-cut-inner { animation: none; }
}
A rolling window with faded edges
Widening the slot to three word heights shows the word coming and the word going as well as the one in place, which turns a swap into a roll. A mask-image fades both edges so the neighbours dissolve rather than being cut off by a hard line. The word list needs more duplicates than the single line version: with a three line window the last two words have to repeat the first two, or the bottom of the window empties out on the final pass. Seven elements cover four unique words. The timing is linear here rather than eased, because this is one continuous movement and not a series of stops.
HTML
<span class="tk-roll-slot">
<div class="tk-roll-inner">
<span class="ticker-word">tests.</span>
<span class="ticker-word">docs.</span>
<span class="ticker-word">types.</span>
<span class="ticker-word">no deps.</span>
<!-- the first two repeat, so the window never runs out -->
<span class="ticker-word">tests.</span>
<span class="ticker-word">docs.</span>
<span class="ticker-word">types.</span>
</div>
</span>
CSS
/* three words tall, with both edges faded out */
.tk-roll-slot {
height: 3.45em;
overflow: hidden;
position: relative;
-webkit-mask-image: linear-gradient(180deg,
transparent 0%, #000 34%, #000 66%, transparent 100%);
mask-image: linear-gradient(180deg,
transparent 0%, #000 34%, #000 66%, transparent 100%);
}
.tk-roll-inner {
display: flex;
flex-direction: column;
/* linear, because this is one continuous movement rather
than a set of stops */
animation: ticker-roll 10s linear infinite;
}
/* four word heights per cycle, which lands word five where
word one started */
@keyframes ticker-roll {
from { transform: translateY(0); }
to { transform: translateY(-4.6em); }
}
@media (prefers-reduced-motion: reduce) {
.tk-roll-inner { animation: none; }
}
How it works
A fixed height container with overflow: hidden clips the view to exactly one word. Inside it the words are stacked in a flex column, and the @keyframes rule moves that column upward one word height at a time. Each pair of keyframes holds a position for most of the cycle before moving to the next, which is what produces the pause between words. A duplicate of the first word at the end of the list makes the loop back to the start invisible.
Every measurement is in em, and that is deliberate. The slot is 1.15em tall, each word is 1.15em tall, and the keyframes move the column in multiples of 1.15em. Because all of them resolve against the same font-size, changing that one value rescales the whole ticker and everything still lines up. Do the same thing in pixels and the ticker works at one size and drifts at every other, which is the kind of bug that only shows up on a phone.
The line has to be a flex container. A <span> is an inline box by default, and height and overflow do not apply to a non-replaced inline box, so a ticker built from plain spans clips nothing and shows all four words stacked. Making the line display: flex turns the slot into a flex item, which is a block level box, and both properties start working. This was a real bug in this file, and the symptom (all the words visible at once, in a column) looks nothing like a display problem.
The keyframe stops are pairs. Zero and 18% both sit at translateY(0), so the first word is held for nearly a fifth of the cycle, then 18% to 25% moves the column up one word height. The pattern repeats four times. The timing function is ease-in-out, and here that is the right choice rather than a trap: each segment is a discrete move between two genuine rest positions, so easing into and out of every one of them is exactly the intent. Compare the elastic bounce, where an eased multi stop animation is trying to be one continuous movement and stalls at every stop instead.
The last word in the list is a copy of the first. Over the cycle the column travels 4.6em, which is four word heights, so the word on screen at 100% is the fifth one. At the moment the animation loops it snaps back to translateY(0), and because the word showing before and after that snap is identical, the jump is invisible. Take the duplicate out and the reader watches the column race backwards through every word once per cycle.
Each word is a flex box in its own right, with align-items: center and line-height: 1, so the glyphs sit in the middle of their 1.15em slot regardless of how far the ascenders and descenders reach. That matters because any mismatch between the slot height and the word height accumulates down the stack: an error of 0.05em per word is 0.2em by the fourth one, which is enough to show the top of the next word peeking into the slot. white-space: nowrap keeps a two word phrase on one line, since a wrap would double that word's height and throw the whole column out.
CSS properties used
overflowhiddenon the slot is what limits the view to one word. It only works because the slot is a block level box, which the flex parent makes it.height1.15emon both the slot and each word. Expressed inemso the ticker rescales withfont-sizeinstead of drifting.transformtranslateYin multiples of the word height. Composited, so the movement costs no layout, and unaffected by the surrounding text flow.animation-timing-functionease-in-out, applied to each segment. Correct here because every keyframe stop is a real resting position rather than a waypoint in one continuous move.displayflexon the line, which turns the inline slot into a block level flex item so thatheightandoverflowapply to it at all.white-spacenowrapon each word, so a phrase never wraps to two lines and doubles the height of one item in the column.
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 |
CSS Masks | 120 | 53 | 15.4 | 120 |
prefers-reduced-motion | 74 | 63 | 10.1 | 79 |
Figures come from Can I Use. The ticker itself needs nothing recent. The mask row applies only to the second variant, where a mask-image fades the top and bottom edges of the window, and it is the one number here worth checking against your own traffic: unprefixed mask-image is a Chrome 120 feature. Older Chromium builds understand -webkit-mask-image with the same value, so declare both. Without either the fade is simply absent and the words are clipped hard at the window edges, which still works.
Accessibility notes
Every word is in the accessibility tree the whole time, because overflow: hidden only affects painting. A screen reader will read the full list, duplicate included, as one run of text. That is usually acceptable for a decorative headline, and it means the sentence a reader hears is not the sentence anyone sees. If the ticker is decorative, mark the slot aria-hidden="true" and put the one canonical phrasing in the markup as visually hidden text.
Motion inside a headline sits right where the reader is trying to read, which makes it more disruptive than the same movement in the margin would be. Under prefers-reduced-motion: reduce, stop the animation and let one word stand. Both variants below do that by holding the first word rather than by slowing the cycle down, because a slower loop is still a loop.
Duplicate the first word for the loop and you have duplicated it for search engines and screen readers too. If the phrase matters for indexing, the visible ticker is not the place to rely on it. Put the real sentence in a heading and let the ticker be an ornament on top of it.
Keep the cycle long enough to read. Roughly one second per word is the floor for a short word, and less than that turns the headline into something the reader has to wait out rather than read. The demo runs four words in four seconds, which is about as fast as it should go.
What you can build with it
- Hero headlines. One fixed phrase and one rotating word, which lets a landing page make several claims in the space of one line.
- Feature lists. Cycling through what a product handles, where the alternative is a bulleted list nobody reads to the end of.
- Testimonial attributions. Rotating company names or job titles under a quote, which keeps a single testimonial block doing several jobs.
- Status and activity lines. A short list of recent events cycling in a fixed slot, using the roll variant so the neighbours are visible above and below.
- Loading messages. Successive lines of copy during a long operation, using hard cuts rather than a slide for a terminal feel.
Mistakes worth avoiding
- Leaving the line as inline elements.
heightandoverflowdo not apply to inline boxes, so nothing is clipped and all the words show at once as a column. - Forgetting the duplicate first word. The loop snaps the column back through every word at the end of each cycle, which reads as a glitch rather than as a rewind.
- Mixing units between the slot height, the word height and the keyframes. Any mismatch compounds down the stack until the next word is visible above or below the one that should be showing.
- Sizing anything in pixels. The ticker lines up at the size it was built at and drifts at every other, so the bug appears when the headline scales down on a phone.
- Running it too fast. Four words in two seconds is unreadable, and a reader who cannot finish a word before it leaves will stop trying rather than watch it come round again.
Frequently asked questions
How do you make rotating text in CSS?
overflow: hidden, then animate the column with translateY in multiples of that word height. Add a copy of the first word at the end so the loop back to the start is invisible.Why are all my words showing at once?
height and overflow have no effect on a non-replaced inline element, so nothing gets clipped. Make the line display: flex, or set the slot itself to display: inline-block.Why does the ticker drift out of alignment?
em value in all three places.How do I make the words cut instead of slide?
animation-timing-function: steps(1, end). Each segment then holds its starting value for the whole segment and jumps at the end, so the word swaps in a single frame. The first variant on this page does that.