# Marquee

> A strip that loops its items only once CSS scroll-driven animations detect they overflow

A React component for projects built on shadcn/ui. The [interactive docs](https://alexandrejoly.me/components/marquee) show each example live.

```tsx
import { buttonVariants } from "@/components/ui/button";
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

const ITEMS = Array.from({ length: 15 }, (_, index) => ({
  href: `#item-${index + 1}`,
  label: `Item ${index + 1}`,
}));

export const MarqueeDemo = () => (
  <Marquee className="w-full">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-x py-1">
      <MarqueeContent pauseOnHover>
        {ITEMS.map((item) => (
          <MarqueeItem key={item.href}>
            <a className={buttonVariants({ variant: "link" })} href={item.href}>
              {item.label}
            </a>
          </MarqueeItem>
        ))}
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

## Installation

With the shadcn CLI:

```bash
pnpm dlx shadcn@latest add @alexandrejoly/marquee
```

Without the `@alexandrejoly` namespace, pass the registry URL:

```bash
pnpm dlx shadcn@latest add https://alexandrejoly.me/r/marquee.json
```

Or by hand:

1. Install the dependencies:

   ```bash
   pnpm add @base-ui/react
   ```
2. Add the shadcn components it builds on:

   ```bash
   pnpm dlx shadcn@latest add button
   ```
3. Add the styles to your global CSS file:

   ```css
   @theme inline {
     --animate-marquee: marquee var(--marquee-duration, 40s) linear infinite;
     --animate-marquee-reverse: marquee var(--marquee-duration, 40s) linear infinite reverse;
     --animate-marquee-vertical: marquee-vertical var(--marquee-duration, 40s) linear infinite;
     --animate-marquee-overflow: marquee-overflow linear both;
   }

   @keyframes marquee {
     to {
       transform: translateX(-50%);
     }
   }

   @keyframes marquee-vertical {
     to {
       transform: translateY(-50%);
     }
   }

   @keyframes marquee-overflow {
     from, to {
       --marquee-overflowing: 1;
     }
   }
   ```
4. Copy the source into `components/ui/marquee.tsx`, then update the import paths to match your aliases:

   ```tsx
   "use client";

   import { mergeProps } from "@base-ui/react/merge-props";
   import {
     Children,
     createContext,
     isValidElement,
     use,
     useCallback,
     useEffect,
     useMemo,
     useRef,
     useState,
   } from "react";
   import { Button } from "@/components/ui/button";
   import { cn } from "@/lib/utils";
   import { PlayIcon, PauseIcon } from "@phosphor-icons/react";

   interface MarqueeContextValue {
     paused: boolean;
     setPaused: (paused: boolean) => void;
   }

   const MarqueeContext = createContext<MarqueeContextValue | null>(null);

   function useMarquee() {
     const context = use(MarqueeContext);
     if (!context) {
       throw new Error("useMarquee must be used within a Marquee.");
     }
     return context;
   }

   type MarqueeProps = React.ComponentProps<"div"> & {
     defaultPaused?: boolean;
     onPausedChange?: (paused: boolean) => void;
     paused?: boolean;
   };

   function Marquee({
     className,
     defaultPaused = false,
     onPausedChange,
     paused: pausedProp,
     ...props
   }: MarqueeProps) {
     const [uncontrolledPaused, setUncontrolledPaused] = useState(defaultPaused);
     const paused = pausedProp ?? uncontrolledPaused;

     const setPaused = useCallback(
       (value: boolean) => {
         setUncontrolledPaused(value);
         onPausedChange?.(value);
       },
       [onPausedChange]
     );

     const context = useMemo(() => ({ paused, setPaused }), [paused, setPaused]);

     return (
       <MarqueeContext value={context}>
         <div
           className={cn(
             "group/marquee flex items-center gap-2",
             "supports-[timeline-scope:none]:animate-marquee-overflow supports-[timeline-scope:none]:[animation-timeline:--marquee] supports-[timeline-scope:none]:[timeline-scope:--marquee]",
             className
           )}
           data-paused={paused || undefined}
           data-slot="marquee"
           {...props}
         />
       </MarqueeContext>
     );
   }

   function MarqueeViewport({
     children,
     className,
     ...props
   }: React.ComponentProps<"div">) {
     return (
       <div
         className={cn(
           "group/marquee-viewport flex min-w-0 flex-1 scroll-p-[50%] flex-col gap-4 self-stretch overflow-hidden [--scroll-fade-reveal:0px] has-[[data-slot=marquee-copy]:not([aria-hidden])_:focus-visible]:[--scroll-fade-reveal:initial]",
           "[scroll-timeline:--marquee_inline] has-[>div>[data-slot=marquee-content][data-orientation=vertical]]:[scroll-timeline:--marquee_block]",
           className
         )}
         data-slot="marquee-viewport"
         {...props}
       >
         <div className="supports-[animation-timeline:scroll()]:animate-marquee-overflow contents [--scroll-fade-reveal:initial] supports-[animation-timeline:scroll()]:[animation-timeline:--marquee]">
           {children}
         </div>
       </div>
     );
   }

   const TRACK_ANIMATION = {
     forward:
       "not-supports-[animation-timeline:scroll()]:motion-safe:animate-marquee [@container_style(--marquee-overflowing:1)]:motion-safe:animate-marquee",
     reverse:
       "not-supports-[animation-timeline:scroll()]:motion-safe:animate-marquee-reverse [@container_style(--marquee-overflowing:1)]:motion-safe:animate-marquee-reverse",
     vertical:
       "not-supports-[animation-timeline:scroll()]:motion-safe:animate-marquee-vertical [@container_style(--marquee-overflowing:1)]:motion-safe:animate-marquee-vertical",
   } as const;

   function useUntabbableClone() {
     const cloneRef = useRef<HTMLDivElement>(null);
     const [inert, setInert] = useState(true);

     useEffect(() => {
       const clone = cloneRef.current;
       if (!clone) {
         return;
       }
       const skipTab = () => {
         for (const element of clone.querySelectorAll<HTMLElement>("*")) {
           if (element.tabIndex >= 0) {
             element.tabIndex = -1;
           }
         }
       };
       skipTab();
       setInert(false);
       const observer = new MutationObserver(skipTab);
       observer.observe(clone, {
         attributeFilter: ["tabindex"],
         childList: true,
         subtree: true,
       });
       return () => observer.disconnect();
     }, []);

     return { cloneRef, inert };
   }

   type MarqueeOrientation = "horizontal" | "vertical";

   const MarqueeOrientationContext =
     createContext<MarqueeOrientation>("horizontal");

   type MarqueeContentProps = React.ComponentProps<"div"> & {
     orientation?: MarqueeOrientation;
     pauseOnHover?: boolean;
     reverse?: boolean;
   };

   function MarqueeContent({
     children,
     className,
     orientation = "horizontal",
     pauseOnHover = false,
     reverse = false,
     ...props
   }: MarqueeContentProps) {
     const items = Children.toArray(children).filter(isValidElement);
     const { cloneRef, inert } = useUntabbableClone();
     const vertical = orientation === "vertical";
     const copy = cn("flex shrink-0", vertical ? "flex-col" : "items-center");
     const clone =
       "supports-[animation-timeline:scroll()]:not-[@container_style(--marquee-overflowing:1)]:hidden group-has-[[data-slot=marquee-copy]:not([aria-hidden])_:focus-visible]/marquee-viewport:hidden";

     return (
       <MarqueeOrientationContext value={orientation}>
         <div
           className={cn(
             vertical ? "flex h-max flex-col" : "flex w-max items-center",
             vertical && TRACK_ANIMATION.vertical,
             !vertical && TRACK_ANIMATION[reverse ? "reverse" : "forward"],
             "group-focus-within/marquee-viewport:[animation-play-state:paused] group-has-[[data-slot=marquee-copy]:not([aria-hidden])_:focus-visible]/marquee-viewport:animate-none group-data-paused/marquee:[animation-play-state:paused]",
             pauseOnHover &&
               "group-hover/marquee-viewport:[animation-play-state:paused]",
             className
           )}
           data-direction={reverse ? "reverse" : "forward"}
           data-orientation={orientation}
           data-slot="marquee-content"
           {...props}
         >
           <div className={copy} data-slot="marquee-copy">
             {items}
           </div>
           <div
             aria-hidden
             className={cn(copy, clone)}
             data-slot="marquee-copy"
             inert={inert}
             ref={cloneRef}
           >
             {items}
           </div>
         </div>
       </MarqueeOrientationContext>
     );
   }

   function MarqueeItem({ className, ...props }: React.ComponentProps<"div">) {
     const orientation = use(MarqueeOrientationContext);
     return (
       <div
         className={cn(
           orientation === "horizontal" ? "px-4" : "py-2 text-center",
           className
         )}
         data-slot="marquee-item"
         {...props}
       />
     );
   }

   function MarqueePause(props: React.ComponentProps<typeof Button>) {
     const { paused, setPaused } = useMarquee();

     return (
       <Button
         data-slot="marquee-pause"
         {...mergeProps<typeof Button>(
           {
             className:
               "motion-reduce:hidden supports-[timeline-scope:none]:not-[@container_style(--marquee-overflowing:1)]:hidden",
             onClick: () => setPaused(!paused),
             size: "icon",
             variant: "ghost",
           },
           props
         )}
       >
         {paused ? (
           <PlayIcon aria-hidden />
         ) : (
           <PauseIcon aria-hidden />
         )}
         <span className="sr-only">{paused ? "Play" : "Pause"}</span>
       </Button>
     );
   }

   export { Marquee, MarqueeContent, MarqueeItem, MarqueePause, MarqueeViewport };
   ```

## Usage

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";
```

```tsx
<Marquee>
  <MarqueePause />
  <MarqueeViewport>
    <MarqueeContent>
      <MarqueeItem>Item 1</MarqueeItem>
      <MarqueeItem>Item 2</MarqueeItem>
    </MarqueeContent>
  </MarqueeViewport>
</Marquee>
```

## Examples

### Pause on hover

A slower track, from `[--marquee-duration:60s]`, pauses under the pointer so longer items can be read.

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

const ITEMS = Array.from(
  { length: 4 },
  (_, index) => `A longer item to read, number ${index + 1}`
);

export const MarqueePauseOnHover = () => (
  <Marquee className="w-full">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-x">
      <MarqueeContent className="[--marquee-duration:60s]" pauseOnHover>
        {ITEMS.map((item) => (
          <MarqueeItem key={item}>{item}</MarqueeItem>
        ))}
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

### Reverse

Two tracks in one marquee, the second with `reverse`, pass each other.

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

const itemsFrom = (first: number) =>
  Array.from({ length: 8 }, (_, index) => `Item ${first + index}`);

const FRONT = itemsFrom(1);
const BACK = itemsFrom(9);

export const MarqueeReverse = () => (
  <Marquee className="w-full">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-x">
      <MarqueeContent>
        {FRONT.map((name) => (
          <MarqueeItem key={name}>{name}</MarqueeItem>
        ))}
      </MarqueeContent>
      <MarqueeContent reverse>
        {BACK.map((name) => (
          <MarqueeItem key={name}>{name}</MarqueeItem>
        ))}
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

### Vertical

A vertical track, faded at the top and bottom, runs on a shorter cycle from `[--marquee-duration:12s]`.

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

const ITEMS = Array.from({ length: 6 }, (_, index) => `Item ${index + 1}`);

export const MarqueeVertical = () => (
  <Marquee className="h-40">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-y">
      <MarqueeContent
        className="[--marquee-duration:12s]"
        orientation="vertical"
      >
        {ITEMS.map((item) => (
          <MarqueeItem key={item}>{item}</MarqueeItem>
        ))}
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

### Content that fits

Items narrower than the viewport stand still: no clone, no fade, no pause button. CSS scroll-driven animations detect the overflow.

> Telling that they fit needs CSS scroll-driven animations. Without that support, the items slide even though they fit.

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

export const MarqueeFit = () => (
  <Marquee className="w-full">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-x">
      <MarqueeContent>
        <MarqueeItem>Item 1</MarqueeItem>
        <MarqueeItem>Item 2</MarqueeItem>
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

### Centered when it fits

`items-center-safe` on `MarqueeViewport` centers a track that fits. Once the track overflows, `safe` aligns it to the start again, so the loop runs as usual.

> Telling that they fit needs CSS scroll-driven animations. Without that support, the centered items slide even though they fit.

```tsx
import {
  Marquee,
  MarqueeContent,
  MarqueeItem,
  MarqueePause,
  MarqueeViewport,
} from "@/components/ui/marquee";

export const MarqueeCenter = () => (
  <Marquee className="w-full">
    <MarqueePause />
    <MarqueeViewport className="scroll-fade-x items-center-safe">
      <MarqueeContent>
        <MarqueeItem>Item 1</MarqueeItem>
        <MarqueeItem>Item 2</MarqueeItem>
      </MarqueeContent>
    </MarqueeViewport>
  </Marquee>
);
```

## Accessibility

Screen readers and the Tab key skip the second copy of each track, so they meet every item once. A pointer can still click it.

`MarqueePause` lets people stop the motion: it stops every track where it is, and its name switches between Pause and Play. Place it before `MarqueeViewport` so Tab reaches it first.

Keyboard focus stops the tracks and brings the focused item fully into view. A pointer press, or hovering with `pauseOnHover`, pauses them only while it lasts. Neither replaces `MarqueePause`.

Under `prefers-reduced-motion: reduce` the tracks stand still and `MarqueePause` hides.

Meets WCAG 2.2: [2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) · [2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) · [2.4.11 Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html) · [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)

## API reference

### Marquee

Holds the pause state and lines up `MarqueePause` beside the viewport.

Takes every prop of `div`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultPaused` | `boolean` | `false` | The pause state on first render, when you don’t control it. |
| `paused` | `boolean` | — | The pause state, when you control it. |
| `onPausedChange` | `(paused: boolean) => void` | — | Called when `MarqueePause` is pressed. |

### MarqueeViewport

Clips the tracks. While its items fit, it stands still with no clone, in browsers with CSS scroll-driven animations. Add shadcn’s `scroll-fade-x`, or `scroll-fade-y` on a vertical track, to fade its edges while the items slide: it sets `--scroll-fade-reveal: 0px`, so both fades show although it never scrolls. Its `scroll-padding: 50%` centers the item keyboard focus lands on, so keep it. It clips on both axes, since detecting the overflow needs a scroll container, so it cuts the focus ring of a link inside: give the ring room with `py-1`, or `px-1` around a vertical track.

Takes every prop of `div`.

### MarqueeContent

The track. It renders its child elements twice and a CSS keyframe slides it by one copy per cycle. Bare text is dropped, so wrap it in `MarqueeItem`. Under reduced motion it stays on the first copy.

Takes every prop of `div`.

| Prop or variable | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | `vertical` stacks the items and slides them upward. |
| `reverse` | `boolean` | `false` | Slides the other way. Horizontal only. |
| `pauseOnHover` | `boolean` | `false` | Pauses the track while a pointer is over the viewport. |
| `--marquee-duration` | `CSS time` | `40s` | One full cycle, set from a class: `[--marquee-duration:20s]`. |

### MarqueeItem

One item of the track. Its padding spaces the items and the loop’s seam alike: `px-4` on a horizontal track, `py-2` on a vertical one. Override it with a class such as `px-2`.

Takes every prop of `div`.

### MarqueePause

Pauses and resumes every track. It is a ghost icon button by default; pass `variant` or `size` to change it. It hides whenever the tracks stand still: under reduced motion, and while the items fit in browsers with CSS scroll-driven animations.

Takes every prop of [shadcn Button](https://ui.shadcn.com/docs/components/base/button).

[All components](https://alexandrejoly.me/components.md)
