Infinite Marquee
Every moving ticker looks the same in a screenshot and most of them have a visible stutter at the loop point. The seam is not a timing problem — it is a measurement problem. Two identical copies inside one track, translated by exactly half the track width, is the only version that has no join.
Live demo
The prompt
Create a marquee — a horizontally scrolling row of items that loops with no visible join. Structure: - A viewport element with overflow: hidden. - Inside it, one track element containing the same set of items TWICE, as two sibling groups. The second group must be a byte-identical copy of the first and must carry aria-hidden="true". - The track is display: flex, width: max-content. Each group is display: flex with its own internal gap. - Put the gap INSIDE each group, never between the groups. A margin between groups is what creates the seam. Motion: - Animate the track with transform: translateX(0) to translateX(-50%), linear, infinite, 20 to 40 seconds depending on content length. - Because the track contains exactly two identical groups, -50% is exactly one group width. That is why the loop is seamless. - Animate transform only. Animating left or margin-left forces layout on every frame. Control: - Pause on hover and on focus-within, using animation-play-state: paused. Any auto-moving content that runs longer than five seconds needs a way to stop. - Under prefers-reduced-motion: reduce, remove the animation entirely and let the row scroll horizontally by hand instead of freezing it mid-word. Constraints: - The row must not trap focus or steal keyboard navigation; items are static text, not links, unless they are real links. - Content must remain readable at the loop point — no half-visible words at reduced motion. - Do not use this inside a form, near an input, or as the primary headline. Fallback: - Without JavaScript this works as-is, because the whole effect is CSS. Do not animate with requestAnimationFrame unless you need direction changes on drag.
What actually makes it work
- Two copies, one track. A single copy translated by -100% leaves a gap the width of the viewport at the moment it resets. Duplicating the content and translating by -50% means the second copy has already replaced the first when the animation restarts.
- The gap belongs inside the group. If the gap is a margin between the two groups, the spacing at the seam is doubled and the rhythm visibly stutters once per loop. Put
gapon each group and nothing between them. width: max-content. Without it the flex track is constrained by its parent, the groups compress, and the -50% calculation no longer matches the content width. This single line is what makes the maths true.- Pause is not optional. Content that moves for more than five seconds and cannot be stopped fails WCAG 2.2.2.
animation-play-stateon:hoverand:focus-withinis the cheapest compliant answer. - Freeze is the wrong reduced-motion response. Stopping the animation mid-scroll can leave a word half-clipped. Under reduced motion, drop the animation and allow horizontal scrolling instead.
Compatibility and performance
| Aspect | Detail |
|---|---|
| transform animation | Universal. Runs on the compositor; no layout and no paint after the first frame. |
| animation-play-state | Universal. |
| Cost | One composited layer the width of the duplicated track. Long rows with images get expensive — keep the payload per item small. |
| Memory | The track is twice the content width. Above roughly 4000px of traffic-strip content, consider a JS-driven approach that recycles nodes. |
One practical note: the duplicate group is a real DOM cost, not a visual one. Screen readers would otherwise announce everything twice, which is why the second group carries aria-hidden="true". Skipping that attribute is the most common accessibility defect in this pattern.
When not to use it
Don't put a sentence in it. Anything a visitor has to read cannot be moving at a constant speed. A ticker is for names, logos, categories and tags.
Don't use it as the only place information appears. If the strip is the sole presentation of a category list, anyone who stops the animation or uses a screen reader loses half of it. Provide a static list elsewhere on the page.
Don't run it in a hero next to a CTA. Two things moving at once — a marquee and a pulsing button — competes for the same attention and neither wins.