Browse the docs
MotionNo. 78 of 109
Marquee
Content scrolling in a seamless loop, pausing on focus and off screen.
Installation
pnpm dlx shadcn@latest add @corsair-ui/marqueeNo setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.
- Also adds
- utilsuse-in-view
- npm
clsxtailwind-merge
Examples
Ports of call
Hover to pause; tabbing to a link stops it too.
Both ways
AnchorCompassShipSailboatLife buoyFlag
AnchorCompassShipSailboatLife buoyFlag
Vertical
Salvadorberth 1
Cartagenaberth 2
Havanaberth 3
Nassauberth 4
Port Royalberth 5
Tortugaberth 6
Veracruzberth 7
API
| Prop | Type | Default |
|---|---|---|
directionWhich way the content travels. | "left" | "right" | "up" | "down" | "left" |
durationSeconds to travel its own length. | number | 20 |
gapSpace between items, any CSS length. | string | "1rem" |
pauseOnHoverHold still under the pointer. | boolean | false |
fade / fadeAmountFade the edges, in %. | boolean / number | true / 10 |
repeatCopies in the loop; raise it for short content. | number | 2 |
Accessibility
- The copies are aria-hidden and inert: one set of links for keyboards and screen readers.
- It stops while a link inside has focus, and pauses off screen.
- With prefers-reduced-motion it holds still and scrolls by hand.
Compatibility
- Tailwind CSS
- 3.4 and 4, both checked in CI
- React
- 19
- Rendering
- Client component ("use client")
- Motion
- Respects prefers-reduced-motion
Source
1 file, installed as source you own
marquee.tsxView on GitHub"use client";
import { useMemo, type ComponentProps, type CSSProperties, type Ref } from "react";
import { useInView } from "@/registry/default/hooks/use-in-view";
import { cn } from "@/registry/default/lib/utils";
function mergeRefs<T>(...refs: (Ref<T> | undefined)[]) {
return (node: T | null) => {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
};
}
interface MarqueeProps extends ComponentProps<"div"> {
/** Which way the content travels. */
direction?: "left" | "right" | "up" | "down";
/** Seconds for the content to travel its own length. */
duration?: number;
/** Space between items, and between the end of the content and its next copy. Any CSS length. */
gap?: string;
/** Hold still while the pointer is over it. */
pauseOnHover?: boolean;
/** Fade the content out at both edges. */
fade?: boolean;
/** How much of each edge the fade covers, in percent. */
fadeAmount?: number;
/**
* Copies of the content in the loop. Two are enough when the content is
* at least as long as the marquee; raise it for short content in a wide one.
*/
repeat?: number;
}
/**
* Content that scrolls in an endless loop: logos, quotes, ports of call.
* It is one CSS animation of a transform. The copies that make the loop
* seamless are hidden from screen readers and cannot take focus; the loop
* stops while a link inside has keyboard focus, and pauses off screen.
* With `prefers-reduced-motion` it holds still and scrolls by hand instead.
*
* @example
* <Marquee pauseOnHover gap="3rem">
* <span>Salvador</span>
* <span>Cartagena</span>
* <span>Havana</span>
* </Marquee>
*/
function Marquee({
direction = "left",
duration = 20,
gap = "1rem",
pauseOnHover = false,
fade = true,
fadeAmount = 10,
repeat = 2,
className,
style,
ref,
children,
...props
}: MarqueeProps) {
const [observe, inView] = useInView<HTMLDivElement>({ rootMargin: "100px" });
const mergedRef = useMemo(() => mergeRefs(ref, observe), [ref, observe]);
const vertical = direction === "up" || direction === "down";
const copies = Math.max(2, Math.floor(repeat));
const edge = Math.min(Math.max(fadeAmount, 0), 50);
const mask = fade
? `linear-gradient(to ${vertical ? "bottom" : "right"}, transparent, #000 ${edge}%, #000 ${100 - edge}%, transparent)`
: undefined;
return (
<div
ref={mergedRef}
data-slot="marquee"
data-direction={direction}
className={cn(
"group/marquee flex overflow-hidden motion-reduce:overflow-auto",
vertical && "flex-col",
className
)}
style={
{
"--marquee-gap": gap,
"--marquee-copies": copies,
maskImage: mask,
WebkitMaskImage: mask,
...style,
} as CSSProperties
}
{...props}
>
<div
data-slot="marquee-track"
className={cn(
"flex w-max shrink-0 group-focus-within/marquee:[animation-play-state:paused]",
vertical
? "motion-safe:animate-marquee-y h-max w-full flex-col"
: "motion-safe:animate-marquee-x",
pauseOnHover && "group-hover/marquee:[animation-play-state:paused]"
)}
style={{
// Each loop moves the track by one copy of the content.
animationDuration: `${duration}s`,
animationDirection: direction === "right" || direction === "down" ? "reverse" : undefined,
// Inline only when off screen, so hover and focus can still pause it.
animationPlayState: inView ? undefined : "paused",
}}
>
{Array.from({ length: copies }, (_, copy) => (
<div
key={copy}
data-slot="marquee-group"
aria-hidden={copy > 0 ? true : undefined}
inert={copy > 0 ? true : undefined}
className={cn(
"flex shrink-0 gap-[var(--marquee-gap)]",
// Trailing space the size of the gap, so every copy is the same length and the loop has no seam.
vertical
? "flex-col pb-[var(--marquee-gap)]"
: "items-center pr-[var(--marquee-gap)]",
copy > 0 && "motion-reduce:hidden"
)}
>
{children}
</div>
))}
</div>
</div>
);
}
export { Marquee, type MarqueeProps };