{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marquee",
  "title": "Marquee",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An endless horizontal or vertical scroller for logo rows and testimonials. Speed is in pixels per second, it pauses on hover and focus, and it fills any width.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/marquee.tsx",
      "content": "// Ballmac UI: Marquee. https://ui.ballmac.com/components/marquee\n\"use client\"\n\nimport * as React from \"react\"\nimport { animate, type AnimationPlaybackControls } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype MarqueeProps = React.ComponentProps<\"div\"> & {\n  /** Scroll top to bottom instead of left to right. Give the marquee a height. */\n  vertical?: boolean\n  /** Run the other way (right to left becomes left to right, up becomes down). */\n  reverse?: boolean\n  /** Speed in pixels per second, so long and short content move at the same pace. */\n  speed?: number\n  /** Pause while the pointer is over the marquee. Keyboard focus inside always pauses. */\n  pauseOnHover?: boolean\n  /** Space between items and between loops. A number is pixels; strings are any CSS length. */\n  gap?: number | string\n  /** Fade the leading and trailing edges into the background. */\n  fade?: boolean\n}\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\"\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const media = window.matchMedia(REDUCED_QUERY)\n  media.addEventListener(\"change\", onChange)\n  return () => media.removeEventListener(\"change\", onChange)\n}\n\n/** Reduced-motion preference that is false on the server and during hydration, so markup always matches. */\nfunction useReducedMotionSafe() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false\n  )\n}\n\nconst MAX_COPIES = 12\n\nfunction Marquee({\n  vertical = false,\n  reverse = false,\n  speed = 40,\n  pauseOnHover = true,\n  gap = 16,\n  fade = true,\n  className,\n  style,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: MarqueeProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const trackRef = React.useRef<HTMLDivElement>(null)\n  const firstCopyRef = React.useRef<HTMLDivElement>(null)\n  const controls = React.useRef<AnimationPlaybackControls | null>(null)\n  const hovered = React.useRef(false)\n  const focused = React.useRef(false)\n  const reduceMotion = useReducedMotionSafe()\n\n  // One copy's length (content + trailing gap) and how many copies fill the viewport.\n  const [loop, setLoop] = React.useState({ length: 0, copies: 2 })\n\n  React.useEffect(() => {\n    const root = rootRef.current\n    const copy = firstCopyRef.current\n    if (!root || !copy || reduceMotion) return\n    const measure = () => {\n      const length = vertical ? copy.offsetHeight : copy.offsetWidth\n      const viewport = vertical ? root.clientHeight : root.clientWidth\n      // Capped: in a container that sizes to its content, each copy would widen the viewport and loop forever.\n      const copies = length > 0 ? Math.min(MAX_COPIES, Math.max(2, Math.ceil(viewport / length) + 1)) : 2\n      setLoop((prev) => (prev.length === length && prev.copies === copies ? prev : { length, copies }))\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(root)\n    observer.observe(copy)\n    return () => observer.disconnect()\n  }, [vertical, reduceMotion])\n\n  React.useEffect(() => {\n    const track = trackRef.current\n    if (!track || reduceMotion || loop.length <= 0 || speed <= 0) return\n    const axis = vertical ? \"translateY\" : \"translateX\"\n    const start = `${axis}(0px)`\n    const end = `${axis}(${-loop.length}px)`\n    controls.current = animate(\n      track,\n      { transform: reverse ? [end, start] : [start, end] },\n      { duration: loop.length / speed, ease: \"linear\", repeat: Infinity }\n    )\n    if (focused.current || (pauseOnHover && hovered.current)) controls.current.pause()\n    return () => {\n      controls.current?.stop()\n      controls.current = null\n      track.style.transform = \"\"\n    }\n  }, [loop.length, speed, reverse, vertical, reduceMotion, pauseOnHover])\n\n  const sync = () => {\n    const paused = focused.current || (pauseOnHover && hovered.current)\n    if (paused) controls.current?.pause()\n    else controls.current?.play()\n  }\n\n  const copies = reduceMotion ? 1 : loop.copies\n  const gapValue = typeof gap === \"number\" ? `${gap}px` : gap\n  const fadeMask = fade\n    ? `linear-gradient(${vertical ? \"to bottom\" : \"to right\"}, transparent, black 10%, black 90%, transparent)`\n    : undefined\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"marquee\"\n      // Scrollable instead of animated under reduced motion, so keyboard users need to reach it.\n      tabIndex={reduceMotion ? 0 : undefined}\n      data-orientation={vertical ? \"vertical\" : \"horizontal\"}\n      className={cn(\n        \"relative flex max-w-full min-w-0\",\n        vertical ? \"flex-col\" : \"flex-row\",\n        reduceMotion\n          ? cn(vertical ? \"overflow-y-auto\" : \"overflow-x-auto\", \"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\")\n          : \"overflow-hidden\",\n        className\n      )}\n      style={{ \"--marquee-gap\": gapValue, maskImage: fadeMask, WebkitMaskImage: fadeMask, ...style } as React.CSSProperties}\n      onPointerEnter={(event) => {\n        hovered.current = true\n        sync()\n        onPointerEnter?.(event)\n      }}\n      onPointerLeave={(event) => {\n        hovered.current = false\n        sync()\n        onPointerLeave?.(event)\n      }}\n      onFocus={(event) => {\n        focused.current = true\n        sync()\n        onFocus?.(event)\n      }}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          focused.current = false\n          sync()\n        }\n        onBlur?.(event)\n      }}\n      {...props}\n    >\n      <div\n        ref={trackRef}\n        data-slot=\"marquee-track\"\n        className={cn(\"flex shrink-0\", vertical ? \"h-max flex-col\" : \"w-max flex-row\")}\n      >\n        {Array.from({ length: copies }, (_, i) => (\n          <div\n            key={i}\n            ref={i === 0 ? firstCopyRef : undefined}\n            data-slot=\"marquee-content\"\n            aria-hidden={i > 0 || undefined}\n            inert={i > 0 || undefined}\n            className={cn(\n              \"flex shrink-0 gap-(--marquee-gap)\",\n              vertical ? \"flex-col pb-(--marquee-gap)\" : \"flex-row pe-(--marquee-gap)\"\n            )}\n          >\n            {children}\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\nexport { Marquee, type MarqueeProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/marquee.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "animation",
      "scroller",
      "logos",
      "testimonials",
      "ticker",
      "motion"
    ],
    "version": "1.0.0",
    "updated": "2026-09-28",
    "ai": {
      "summary": "Put the items directly inside <Marquee>; it measures one copy, clones as many as the viewport needs (clones are aria-hidden and inert) and loops them with a GPU transform. Under reduced motion it renders a single copy in a scrollable row.",
      "whenToUse": [
        "A row of customer or partner logos under a hero",
        "A wall of short testimonials (vertical, several columns side by side)",
        "A ticker of changelog items, integrations or stats"
      ],
      "whenNotToUse": [
        "Content people must read or act on in full (use a grid or carousel with controls)",
        "Critical navigation or calls to action"
      ],
      "composesWith": [
        "spotlight-card",
        "badge",
        "avatar"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Moves focus into the first copy of the content and pauses the scroll"
        }
      ],
      "customization": [
        "speed in px/s (default 40); reverse; vertical (set a height with className, e.g. h-80)",
        "gap as px number or CSS length (default 16)",
        "fade toggles the edge mask; pauseOnHover (default true)",
        "Style parts with [data-slot=marquee-track] and [data-slot=marquee-content]"
      ]
    },
    "examples": [
      "marquee-demo",
      "marquee-vertical"
    ],
    "url": "https://ui.ballmac.com/components/marquee"
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}