Marquees show up on logo walls, testimonial strips and announcement bars. The basic technique is short, but a few details decide whether the loop is smooth and whether keyboard and screen reader users can live with it. This guide targets Tailwind CSS v4.3 and React 19. It covers the CSS mechanics, a complete component, and then the marquee components that ship with WebberUI.
How the loop works: two copies, shift by -50%
Render the same content twice, side by side, in a track that is wider than its container. Animate the whole track from translateX(0) to translateX(-50%). At the end of each cycle the second copy sits exactly where the first one started, so restarting the animation produces no visible jump.
For -50% to line up, both copies must have exactly the same width, including the trailing gap. If items are spaced with gap-8, give each copy a matching pr-8. Without it, the seam between the copies is one gap short.
Step 1: define the animation in @theme
Tailwind v4 is configured in CSS. Add this to your global stylesheet:
@import "tailwindcss";
@theme inline {
--animate-marquee: marquee var(--marquee-duration, 30s) linear infinite;
@keyframes marquee {
to {
transform: translateX(-50%);
}
}
}That gives you an animate-marquee utility, and the keyframes are only emitted when the utility is used.
The inline keyword matters here. Plain @theme writes the value as a custom property on :root, and var(--marquee-duration) is resolved there, falling back to 30s. Setting --marquee-duration on an element later has no effect. With @theme inline, the utility outputs animation: marquee var(--marquee-duration, 30s) linear infinite directly, so the variable is resolved on the element and each marquee can set its own speed. See the Tailwind theme docs.
Step 2: the React component
"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
type MarqueeProps = {
/** A set of <li> elements; rendered twice */
children: ReactNode;
/** Accessible name for the region, e.g. "Customers" */
label: string;
reverse?: boolean;
/** Time to scroll through one copy */
duration?: string;
};
export function Marquee({
children,
label,
reverse = false,
duration = "30s",
}: MarqueeProps) {
const [paused, setPaused] = useState(false);
return (
<section aria-label={label} className="flex items-center gap-3">
<div
data-paused={paused}
style={{ "--marquee-duration": duration } as CSSProperties}
className="group flex-1 overflow-hidden mask-x-from-90% motion-reduce:overflow-x-auto motion-reduce:mask-none"
>
<div
className={[
"flex w-max animate-marquee motion-reduce:animate-none",
"group-hover:[animation-play-state:paused]",
"group-focus-within:[animation-play-state:paused]",
"group-data-[paused=true]:[animation-play-state:paused]",
reverse ? "[animation-direction:reverse]" : "",
].join(" ")}
>
<ul className="flex shrink-0 items-center gap-8 pr-8">{children}</ul>
<ul
aria-hidden="true"
inert
className="flex shrink-0 items-center gap-8 pr-8 motion-reduce:hidden"
>
{children}
</ul>
</div>
</div>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="shrink-0 rounded-md border px-3 py-1.5 text-sm motion-reduce:hidden"
>
{paused ? "Play" : "Pause"}
</button>
</section>
);
}Usage:
<Marquee label="Customers" duration="40s">
{logos.map((logo) => (
<li key={logo.name}>
<img src={logo.src} alt={logo.name} className="h-8 w-auto" />
</li>
))}
</Marquee>The sections below go through what each class is doing.
Pause on hover and focus
Tailwind v4 has no built-in animation-play-state utility, so use the arbitrary property [animation-play-state:paused] with group-hover:. Note that in v4, hover: and group-hover: are wrapped in @media (hover: hover), so they never fire on touch screens. That is intentional, and it means touch users need another way to pause, which is what the button is for.
group-focus-within: stops the track when a keyboard user tabs onto a link inside it. Without it, the focus ring scrolls away with the content.
Reverse
[animation-direction:reverse] plays the same keyframes backwards. The common "adjacent rows move in opposite directions" testimonial layout is two Marquee instances, one with reverse.
Edge fade
mask-x-from-90% is one of the mask utilities built into Tailwind since v4.1. It fades the outer 10% on each side. On v4.0, use an arbitrary value instead:
<div class="[mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]"></div>Hide the duplicate from assistive technology
The second <ul> exists only for the visual loop. aria-hidden="true" stops screen readers from announcing every logo twice. inert (supported as a prop in React 19) keeps any links inside it out of the tab order. Using aria-hidden alone on content that contains links is a common accessibility bug: keyboard users can still focus an element that has been declared hidden.
prefers-reduced-motion
When the operating system's reduce motion setting is on, the motion-reduce: variants:
- stop the animation (
animate-none) - hide the duplicate copy and the pause button
- drop the mask and make the row horizontally scrollable (
overflow-x-auto), so all content is still reachable
Stopping the animation without handling overflow leaves the second half of the logos clipped and permanently out of view.
Why a pause button
WCAG 2.2 success criterion 2.2.2, Pause, Stop, Hide, requires a way to pause, stop or hide content that moves automatically for more than five seconds. Hover covers mouse users; touch and keyboard users need an explicit control. The button toggles data-paused on the wrapper, and CSS picks it up through group-data-[paused=true]:, so no JavaScript touches the animation itself.
Speed versus content width
duration is the time to scroll through one copy, so wider content appears to move faster. If a page has several marquees that should move at the same speed, derive the duration from the width: measure the first <ul> with a ResizeObserver, divide by the pixels per second you want, and write the result back to --marquee-duration.
A vertical marquee works the same way. Change the keyframes to translateY(-50%), make the track flex-col h-max, and give the outer container a fixed height.
Why not the <marquee> element
The HTML <marquee> element is marked deprecated on MDN; the specification keeps it only so that old pages continue to work. It offers no control over easing, ignores prefers-reduced-motion, and has no standard way for users to pause it. With a CSS animation, speed, direction, pausing and accessibility behaviour all stay in your own code, where you can test and change them.
Marquee components in WebberUI
If you need more than a constant-speed loop, WebberUI has components that already handle the details above. They install with the WebberUI CLI and the source lands in your components/ui/ folder:
- Velocity Marquee: speed follows page scroll velocity, and it briefly reverses when you scroll back quickly. Movement is computed per frame with
useAnimationFramefrommotion/react, and it renders a static row when reduced motion is on. Free. - Testimonial Wall: three columns scrolling in alternating directions, in either an auto-scroll or a scroll-parallax style, with a
pauseOnHoverprop. Free. - 3d Marquee: a tilted 3D grid with columns scrolling up and down in alternation, suited to portfolio or screenshot walls. Pro.
- Team + Logo Wall: a team grid plus a scrolling customer logo strip, ready to drop into an about page. Pro.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add velocity-marquee.enInstall the free Velocity Marquee:
npx webberui@latest add https://webberui.com/r/velocity-marquee.en.jsonBrowse more in the text animation category, and if this is your first install, start with the quick start.