what we're building
a horizontal banner containing a line of text that continuously scrolls from right to left, and loops forever without ever showing a gap or a jump back to the beginning:
live example:
step 1 — the html: duplicate the content
this is the part that trips people up the most, so it comes first. the html isn't just one copy of your text, it's two identical copies sitting right next to each other:
<div class="ticker">
<div class="ticker-track">
<span>welcome to my site ♡</span>
<span>thanks for stopping by!</span>
<span>don't forget to sign the guestbook</span>
<span>welcome to my site ♡</span>
<span>thanks for stopping by!</span>
<span>don't forget to sign the guestbook</span>
</div>
</div>
the outer .ticker is the little window the text scrolls through, it stays a fixed size and hides anything outside it. the inner .ticker-track is the strip that actually moves, and it's twice as long as it needs to be, because it contains your message twice in a row.
step 2 — the outer window
the outer wrapper's job is simple: be a fixed-size box, and hide anything that spills outside of it.
.ticker {
width: 100%;
overflow: hidden;
border: 1px solid #e7d0dd;
border-radius: 4px;
background: #fbf3f8;
padding: 10px 0;
}
overflow: hidden: this is the single most important line here. the inner track is going to be much wider than this box (remember, it's the text doubled up), and without this, the whole doubled strip would just spill out sideways and be fully visible instead of scrolling through a small windowwidth: 100%: makes the ticker stretch to fill whatever container it's placed in. you could give it a fixed pixel width instead if you want a smaller, contained ticker rather than a full-width banner- the border, background, and padding are purely decorative, style this however fits your theme, none of it affects how the scrolling works
step 3 — the moving track
the inner track is what actually scrolls. its width needs to come from its content, not be forced to match its parent, otherwise the browser has nothing to measure the scroll distance against:
.ticker-track {
display: flex;
width: max-content;
white-space: nowrap;
animation: tickerMove 14s linear infinite;
will-change: transform;
}
display: flex: lays all the<span>s out in a single horizontal row, side by side, instead of stacking them or letting them wrapwidth: max-content: tells the track to size itself to exactly as wide as its content needs, rather than shrinking to fit the parent's width. this is essential, if the track were stuck at100%width like its parent, the text inside would just get squeezed or wrapped instead of flowing out sidewayswhite-space: nowrap: a backup measure that stops the browser from ever wrapping the text onto a second line, keeping everything on one continuous horizontal stripanimation: tickerMove 14s linear infinite: this is what actually moves it, and it's explained fully in the next stepwill-change: transform: a hint to the browser that this element'stransformis about to change repeatedly, so it should set up an optimized rendering layer for it ahead of time instead of recalculating one on the fly. without this, some browsers can produce a tiny stutter or hitch right at the exact moment the animation loops back tofrom, since that's when the browser is doing the most work resetting position. this single line smooths that out
will-change isn't something to sprinkle on every element "just in case". it asks the browser to reserve extra memory and gpu resources for that element the whole time the page is open, so overusing it can actually hurt performance instead of helping. it's meant for elements you already know are about to animate constantly, like this ticker track, not as a general-purpose speed boost.step 4 — the animation itself
the movement comes from a @keyframes rule, which is just a named set of instructions describing how something should look at different points in time:
@keyframes tickerMove {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
fromandto: the starting and ending state of the animation. the browser automatically fills in every frame in between, smoothly moving from one to the other, you never have to calculate the in-between positions yourselftranslateX(0): at the very start, the track sits at its normal position, not moved at alltranslateX(-50%): by the end, the track has slid left by 50% of its own width. since the track is exactly double-length (remember step 1), sliding it left by half its width moves it by exactly one full copy of your original text. the second copy is now sitting precisely where the first copy started, so the loop can snap back tofromand repeat with nobody noticing
translateX(-50%) here are two halves of the same idea. the animation only ends up looking seamless because the content is duplicated exactly once (making the track exactly 200% as wide as one copy), and the animation moves it by exactly 50% of that doubled width (which equals 100% of one copy). if you tripled the content instead, you'd need to move it by -33.333% instead, to land on exactly one copy's worth of movement.back on the .ticker-track rule, the animation shorthand ties the keyframes to actual movement:
tickerMove: the name of the@keyframesrule to run, has to match exactly14s: how long one full loop takes. bigger number, slower scroll. this is the main dial to adjust reading speed, more text usually needs a longer duration so it doesn't fly by unreadably fastlinear: the timing curve. unlike theeasecurve used in hover animations, a ticker should move at a perfectly constant speed the whole time,linearmeans no speeding up or slowing down, which is what makes a continuous scroll feel smooth instead of jerkyinfinite: repeats the animation forever instead of stopping after one loop. this is what makes it a proper ticker instead of a one-time scroll
step 5 (bonus) — pause on hover
a nice touch for readability: let people pause the scroll by hovering over it, so they can actually read a line that catches their eye.
.ticker:hover .ticker-track {
animation-play-state: paused;
}
animation-play-state: a property that can be set to eitherrunning(the default) orpaused. setting it topausedfreezes the animation exactly where it currently is, instead of resetting it, and it resumes from that same spot once the rule stops applying.ticker:hover .ticker-track: notice the hover is listening on the outer.tickerwrapper, but the property changes on the inner.ticker-track. this means hovering anywhere inside the ticker's window pauses the scroll, not just hovering the text itself
live example: hover over this one to pause it.
prefers-reduced-motion setting so the ticker holds still for anyone who's turned that on, instead of scrolling regardless:
@media (prefers-reduced-motion: reduce) {
.ticker-track {
animation: none;
}
}
result
a fully css-driven scrolling banner: no javascript, no timers, no manually tracking scroll position. the whole illusion rests on two things working together, doubling the content once, and moving it by exactly half its own width. that same relationship (duplicate content, then translate by the matching fraction) is the exact same trick behind most infinite marquees, logo carousels, and auto-scrolling image strips you'll see around the web, so this pattern is worth remembering beyond just text banners.