Browse the docs
MotionNo. 100 of 148
Marquee
Content scrolling in a seamless loop, pausing on focus and off screen, and draggable by the pointer.
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-viewuse-media-query
- npm
clsxtailwind-merge
Examples
Ports of call
Hover to pause; tabbing to a link stops it too.
Both ways
Vertical
Draggable
Grab it and drag either way; a plain click still follows a link.
Paused from outside
paused holds it still, and it carries on from the same place.
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 |
draggableGrab the loop and drag it either way; it carries on from where it is let go. A press that moves under 5 px is still a click. | boolean | false |
labelNames the scroll box it becomes with reduced motion. | string | "Scrolling content" |
pausedHold it still from outside, such as while a dialog is open. | boolean | false |
Styling
Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=marquee-group]]:text-primary".
- With draggable the root has data-draggable, and data-dragging="true" while it is held; the track pauses then.
- data-slot
marqueemarquee-groupmarquee-track
- State
data-directioncomputeddata-draggablecomputeddata-pausedcomputed
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, and draggable does nothing.
- Dragging is an extra for pointers: nothing is only reachable by dragging, and the click that ends a drag does not follow a link.
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 {
useEffect,
useMemo,
useRef,
type ComponentProps,
type CSSProperties,
type DragEvent,
type MouseEvent,
type PointerEvent,
type Ref,
} from "react";
import { useInView } from "@/registry/default/hooks/use-in-view";
import { useMediaQuery } from "@/registry/default/hooks/use-media-query";
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;
}
};
}
/** How far, in px, the pointer moves before a press becomes a drag, so taps and clicks on links still work. */
const DRAG_THRESHOLD = 5;
interface MarqueeProps extends Omit<ComponentProps<"div">, "draggable"> {
/** 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;
/**
* Hold it still from outside, for example while a dialog opened from it
* is up. It carries on from the same place once this is false again.
*/
paused?: 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;
/**
* Names the marquee for screen readers when `prefers-reduced-motion` turns
* it into a box you scroll by hand: it then becomes a focusable region, so
* keyboard users can scroll it too.
*/
label?: string;
/**
* Let the pointer grab the loop and drag it either way; it carries on from
* where it is let go. A press that moves less than a few pixels is still a
* click, so links inside keep working. With `prefers-reduced-motion` the
* marquee is a scroll box instead, and this does nothing.
*/
draggable?: boolean;
}
/** The loop's CSS animation on the track, where the browser supports looking it up. */
function loopAnimation(track: HTMLElement | null) {
return track
?.getAnimations?.()
.find((animation) => (animation as CSSAnimation).animationName?.startsWith("marquee"));
}
/**
* 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.
* With `draggable`, the pointer can grab the loop: dragging seeks the same
* CSS animation, so the loop stays seamless and there are no scroll listeners.
*
* @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,
paused = false,
fade = true,
fadeAmount = 10,
repeat = 2,
label = "Scrolling content",
draggable = false,
className,
style,
ref,
children,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onClickCapture,
onDragStart,
...props
}: MarqueeProps) {
const [observe, inView] = useInView<HTMLDivElement>({ rootMargin: "100px" });
const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
const root = useRef<HTMLDivElement>(null);
const mergedRef = useMemo(() => mergeRefs(ref, observe, root), [ref, observe]);
// A scroll box has to be reachable from the keyboard. Set on the element, and
// only while it is one, so the moving marquee stays out of the tab order.
useEffect(() => {
const element = root.current;
if (!element) return;
if (reduced) element.tabIndex = 0;
else element.removeAttribute("tabindex");
}, [reduced]);
const vertical = direction === "up" || direction === "down";
const reversed = direction === "right" || direction === "down";
const copies = Math.max(2, Math.floor(repeat));
const track = useRef<HTMLDivElement>(null);
const drag = useRef<{ pointerId: number; start: number; last: number; moving: boolean }>(null);
// Set when a drag ends, so the click the browser fires after it does not follow a link.
const dragged = useRef(false);
const position = (event: PointerEvent<HTMLElement>) => (vertical ? event.clientY : event.clientX);
// Moves the loop by `distance` px by seeking its animation. Setting the
// time (rather than calling pause or play) keeps CSS in charge of pausing.
const seek = (distance: number) => {
const animation = loopAnimation(track.current);
if (!animation || !track.current) return;
const box = track.current.getBoundingClientRect();
const length = (vertical ? box.height : box.width) / copies;
const period = Number(animation.effect?.getComputedTiming().duration);
if (!length || !period) return;
// Forwards, the track moves towards the start as time runs; reversed, away from it.
const time =
Number(animation.currentTime ?? 0) + (distance / length) * period * (reversed ? 1 : -1);
animation.currentTime = ((time % period) + period) % period;
};
const endDrag = (event: PointerEvent<HTMLDivElement>, cancelled: boolean) => {
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
drag.current = null;
if (!current.moving) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
event.currentTarget.removeAttribute("data-dragging");
dragged.current = !cancelled;
};
const dragHandlers = draggable
? {
onPointerDown(event: PointerEvent<HTMLDivElement>) {
onPointerDown?.(event);
dragged.current = false;
if (event.defaultPrevented || event.button !== 0 || drag.current) return;
const start = position(event);
drag.current = { pointerId: event.pointerId, start, last: start, moving: false };
},
onPointerMove(event: PointerEvent<HTMLDivElement>) {
onPointerMove?.(event);
const current = drag.current;
if (!current || current.pointerId !== event.pointerId) return;
const now = position(event);
if (!current.moving) {
if (Math.abs(now - current.start) < DRAG_THRESHOLD) return;
// Only now, so a plain click still lands on the link under the pointer.
current.moving = true;
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.setAttribute("data-dragging", "true");
// Pressing a link focuses it, and focus inside pauses the loop; a drag is not a visit.
const focused = document.activeElement;
if (focused instanceof HTMLElement && event.currentTarget.contains(focused)) {
focused.blur();
}
}
seek(now - current.last);
current.last = now;
},
onPointerUp(event: PointerEvent<HTMLDivElement>) {
onPointerUp?.(event);
endDrag(event, false);
},
onPointerCancel(event: PointerEvent<HTMLDivElement>) {
onPointerCancel?.(event);
endDrag(event, true);
},
onClickCapture(event: MouseEvent<HTMLDivElement>) {
onClickCapture?.(event);
if (!dragged.current) return;
dragged.current = false;
event.preventDefault();
event.stopPropagation();
},
// Links and images would otherwise start a native drag and steal the pointer.
onDragStart(event: DragEvent<HTMLDivElement>) {
onDragStart?.(event);
event.preventDefault();
},
}
: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture, onDragStart };
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}
data-paused={paused ? "" : undefined}
data-draggable={draggable ? "" : undefined}
// Still, it scrolls by hand: a named region, focusable (set below) for the keyboard.
role={reduced ? "region" : undefined}
aria-label={reduced ? label : undefined}
className={cn(
"group/marquee flex overflow-hidden motion-reduce:overflow-auto",
vertical && "flex-col",
// Touch pans across the loop go to the drag; the page still scrolls the other way.
draggable && "motion-safe:cursor-grab motion-safe:select-none",
draggable && (vertical ? "motion-safe:touch-pan-x" : "motion-safe:touch-pan-y"),
draggable && "data-[dragging=true]:cursor-grabbing",
className
)}
style={
{
"--marquee-gap": gap,
"--marquee-copies": copies,
maskImage: mask,
WebkitMaskImage: mask,
...style,
} as CSSProperties
}
{...props}
{...dragHandlers}
>
<div
ref={track}
data-slot="marquee-track"
className={cn(
"flex w-max shrink-0 group-focus-within/marquee:[animation-play-state:paused]",
// Held while dragged; the drag moves it by seeking the animation.
"group-data-[dragging=true]/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: reversed ? "reverse" : undefined,
// Inline only when held or off screen, so hover and focus can still pause it.
animationPlayState: paused || !inView ? "paused" : undefined,
}}
>
{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 };