{ "name": "marquee", "type": "registry:ui", "files": [ { "path": "ui/marquee.tsx", "type": "registry:ui", "content": "\"use client\";\n\nimport { mergeProps } from \"@base-ui/react/merge-props\";\nimport { useRender } from \"@base-ui/react/use-render\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useComposedRefs } from \"@/registry/bases/base/lib/compose-refs\";\nimport { useDirection } from \"@/registry/bases/base/ui/direction\";\n\nconst ROOT_NAME = \"Marquee\";\nconst CONTENT_NAME = \"MarqueeContent\";\n\ntype Side = \"left\" | \"right\" | \"top\" | \"bottom\";\ntype Orientation = \"horizontal\" | \"vertical\";\ntype Direction = \"ltr\" | \"rtl\";\n\ntype RootElement = HTMLDivElement;\ntype ContentElement = HTMLDivElement;\n\ninterface Dimensions {\n width: number;\n height: number;\n}\n\ninterface ElementDimensions {\n rootSize: number;\n contentSize: number;\n}\n\nfunction createResizeObserverStore() {\n const listeners = new Set<() => void>();\n let observer: ResizeObserver | null = null;\n const elements = new Map();\n const refCounts = new Map();\n const isSupported = typeof ResizeObserver !== \"undefined\";\n let notificationScheduled = false;\n\n const snapshotCache = new WeakMap<\n Element,\n WeakMap<\n Element,\n { horizontal: ElementDimensions; vertical: ElementDimensions }\n >\n >();\n\n function notify() {\n if (notificationScheduled) return;\n notificationScheduled = true;\n queueMicrotask(() => {\n notificationScheduled = false;\n for (const callback of listeners) {\n callback();\n }\n });\n }\n\n function cleanup() {\n if (observer) {\n observer.disconnect();\n observer = null;\n }\n elements.clear();\n refCounts.clear();\n }\n\n function subscribe(callback: () => void) {\n listeners.add(callback);\n return () => {\n listeners.delete(callback);\n if (listeners.size === 0) {\n cleanup();\n }\n };\n }\n\n function getSnapshot(\n rootElement: RootElement | null,\n contentElement: ContentElement | null,\n orientation: Orientation,\n ): ElementDimensions | null {\n if (!rootElement || !contentElement) return null;\n\n const rootDims = elements.get(rootElement);\n const contentDims = elements.get(contentElement);\n\n if (!rootDims || !contentDims) return null;\n\n const rootSize =\n orientation === \"vertical\" ? rootDims.height : rootDims.width;\n const contentSize =\n orientation === \"vertical\" ? contentDims.height : contentDims.width;\n\n let rootCache = snapshotCache.get(rootElement);\n if (!rootCache) {\n rootCache = new WeakMap();\n snapshotCache.set(rootElement, rootCache);\n }\n\n let contentCache = rootCache.get(contentElement);\n if (!contentCache) {\n contentCache = {\n horizontal: { rootSize: -1, contentSize: -1 },\n vertical: { rootSize: -1, contentSize: -1 },\n };\n rootCache.set(contentElement, contentCache);\n }\n\n const cached = contentCache[orientation];\n if (cached.rootSize === rootSize && cached.contentSize === contentSize) {\n return cached;\n }\n\n const snapshot = { rootSize, contentSize };\n contentCache[orientation] = snapshot;\n return snapshot;\n }\n\n function observe(\n rootElement: RootElement | null,\n contentElement: Element | null,\n ) {\n if (!isSupported || !rootElement || !contentElement) return;\n\n if (!observer) {\n observer = new ResizeObserver((entries) => {\n let hasChanged = false;\n\n for (const entry of entries) {\n const element = entry.target;\n const { width, height } = entry.contentRect;\n\n const currentData = elements.get(element);\n\n if (\n !currentData ||\n currentData.width !== width ||\n currentData.height !== height\n ) {\n elements.set(element, { width, height });\n hasChanged = true;\n }\n }\n\n if (hasChanged) {\n notify();\n }\n });\n }\n\n refCounts.set(rootElement, (refCounts.get(rootElement) ?? 0) + 1);\n refCounts.set(contentElement, (refCounts.get(contentElement) ?? 0) + 1);\n\n observer.observe(rootElement);\n observer.observe(contentElement);\n\n const rootRect = rootElement.getBoundingClientRect();\n const contentRect = contentElement.getBoundingClientRect();\n\n const rootData = { width: rootRect.width, height: rootRect.height };\n const contentData = {\n width: contentRect.width,\n height: contentRect.height,\n };\n\n elements.set(rootElement, rootData);\n elements.set(contentElement, contentData);\n\n if (\n rootData.width > 0 &&\n rootData.height > 0 &&\n contentData.width > 0 &&\n contentData.height > 0\n ) {\n notify();\n }\n }\n\n function unobserve(\n rootElement: RootElement | null,\n contentElement: Element | null,\n ) {\n if (!observer || !rootElement || !contentElement) return;\n\n const rootCount = (refCounts.get(rootElement) ?? 1) - 1;\n const contentCount = (refCounts.get(contentElement) ?? 1) - 1;\n\n if (rootCount <= 0) {\n observer.unobserve(rootElement);\n elements.delete(rootElement);\n refCounts.delete(rootElement);\n } else {\n refCounts.set(rootElement, rootCount);\n }\n\n if (contentCount <= 0) {\n observer.unobserve(contentElement);\n elements.delete(contentElement);\n refCounts.delete(contentElement);\n } else {\n refCounts.set(contentElement, contentCount);\n }\n }\n\n return {\n subscribe,\n getSnapshot,\n observe,\n unobserve,\n };\n}\n\nconst resizeObserverStore = createResizeObserverStore();\n\nfunction useResizeObserverStore(\n rootRef: React.RefObject,\n contentRef: React.RefObject,\n orientation: Orientation,\n): ElementDimensions | null {\n const onSubscribe = React.useCallback(\n (callback: () => void) => resizeObserverStore.subscribe(callback),\n [],\n );\n\n const getSnapshot = React.useCallback(\n () =>\n resizeObserverStore.getSnapshot(\n rootRef.current,\n contentRef.current,\n orientation,\n ),\n [rootRef, contentRef, orientation],\n );\n\n return React.useSyncExternalStore(onSubscribe, getSnapshot, getSnapshot);\n}\n\ninterface MarqueeContextValue {\n side: Side;\n orientation: Orientation;\n dir: Direction;\n speed: number;\n loopCount: number;\n contentRef: React.RefObject;\n rootRef: React.RefObject;\n autoFill: boolean;\n pauseOnHover: boolean;\n pauseOnKeyboard: boolean;\n reverse: boolean;\n paused: boolean;\n}\n\nconst MarqueeContext = React.createContext(null);\n\nfunction useMarqueeContext(consumerName: string) {\n const context = React.useContext(MarqueeContext);\n if (!context) {\n throw new Error(`\\`${consumerName}\\` must be used within \\`${ROOT_NAME}\\``);\n }\n return context;\n}\n\ninterface MarqueeProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {\n side?: Side;\n dir?: Direction;\n speed?: number;\n delay?: number;\n loopCount?: number;\n gap?: string | number;\n autoFill?: boolean;\n pauseOnHover?: boolean;\n pauseOnKeyboard?: boolean;\n reverse?: boolean;\n}\n\nfunction Marquee(props: MarqueeProps) {\n const {\n side = \"left\",\n dir: dirProp,\n speed = 50,\n delay = 0,\n loopCount = 0,\n gap = \"1rem\",\n render,\n autoFill = false,\n pauseOnHover = false,\n pauseOnKeyboard = false,\n reverse = false,\n className,\n style: styleProp,\n ref,\n ...marqueeProps\n } = props;\n\n const orientation: Orientation =\n side === \"top\" || side === \"bottom\" ? \"vertical\" : \"horizontal\";\n\n const contextDir = useDirection();\n const dir = dirProp ?? contextDir;\n\n const rootRef = React.useRef(null);\n const contentRef = React.useRef(null);\n const composedRef = useComposedRefs(ref, rootRef);\n\n const [paused, setPaused] = React.useState(false);\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (pauseOnKeyboard && event.key === \" \") {\n event.preventDefault();\n setPaused((prev) => !prev);\n }\n },\n [pauseOnKeyboard],\n );\n\n const dimensions = useResizeObserverStore(rootRef, contentRef, orientation);\n\n const duration = React.useMemo(() => {\n const safeSpeed = Math.max(0.001, speed);\n\n if (!dimensions) {\n const defaultDistance = autoFill ? 1000 : 2000;\n return defaultDistance / safeSpeed;\n }\n\n const { rootSize, contentSize } = dimensions;\n\n if (autoFill) {\n const multiplier =\n contentSize < rootSize ? Math.ceil(rootSize / contentSize) : 1;\n return (contentSize * multiplier) / safeSpeed;\n } else {\n return contentSize < rootSize\n ? rootSize / safeSpeed\n : contentSize / safeSpeed;\n }\n }, [dimensions, speed, autoFill]);\n\n const style = React.useMemo(\n () => ({\n \"--marquee-duration\": `${duration}s`,\n \"--marquee-gap\": gap,\n \"--marquee-delay\": `${delay}s`,\n \"--marquee-loop-count\":\n loopCount === 0 || loopCount === Infinity\n ? \"infinite\"\n : loopCount.toString(),\n ...styleProp,\n }),\n [duration, gap, delay, loopCount, styleProp],\n );\n\n const contextValue = React.useMemo(\n () => ({\n side,\n orientation,\n dir,\n speed,\n loopCount,\n contentRef,\n rootRef,\n autoFill,\n paused,\n pauseOnHover,\n pauseOnKeyboard,\n reverse,\n }),\n [\n side,\n orientation,\n dir,\n speed,\n loopCount,\n autoFill,\n paused,\n pauseOnHover,\n pauseOnKeyboard,\n reverse,\n ],\n );\n\n const element = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n role: \"marquee\" as React.AriaRole,\n \"aria-live\": \"off\",\n dir,\n tabIndex: pauseOnKeyboard ? 0 : undefined,\n ref: composedRef,\n className: cn(\n \"relative flex overflow-hidden motion-reduce:animate-none\",\n orientation === \"vertical\" && \"h-full flex-col\",\n orientation === \"horizontal\" && \"w-full\",\n paused && \"**:paused\",\n pauseOnHover && \"group\",\n pauseOnKeyboard &&\n \"rounded-md focus-visible:border-ring focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n className,\n ),\n style,\n onKeyDown: pauseOnKeyboard ? onKeyDown : undefined,\n },\n marqueeProps,\n ),\n render,\n state: {\n slot: \"marquee\",\n orientation,\n },\n });\n\n return (\n \n
\n {element}\n
\n
\n );\n}\n\nconst marqueeContentVariants = cva(\n \"flex min-w-full shrink-0 gap-(--marquee-gap)\",\n {\n variants: {\n side: {\n left: \"animate-marquee-left\",\n right: \"animate-marquee-right\",\n top: \"min-h-full min-w-auto animate-marquee-up flex-col\",\n bottom: \"min-h-full min-w-auto animate-marquee-down flex-col\",\n },\n dir: {\n ltr: \"\",\n rtl: \"\",\n },\n pauseOnHover: {\n true: \"group-hover:[animation-play-state:paused]\",\n false: \"\",\n },\n reverse: {\n true: \"[animation-direction:reverse]\",\n false: \"\",\n },\n },\n compoundVariants: [\n {\n side: \"left\",\n dir: \"rtl\",\n className: \"animate-marquee-left-rtl\",\n },\n {\n side: \"right\",\n dir: \"rtl\",\n className: \"animate-marquee-right-rtl\",\n },\n ],\n defaultVariants: {\n side: \"left\",\n dir: \"ltr\",\n pauseOnHover: false,\n reverse: false,\n },\n },\n);\n\ninterface MarqueeContentProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction MarqueeContent(props: MarqueeContentProps) {\n const {\n className,\n render,\n ref,\n children,\n style: styleProp,\n ...contentProps\n } = props;\n\n const context = useMarqueeContext(CONTENT_NAME);\n const composedRef = useComposedRefs(ref, context.contentRef);\n\n const isVertical = context.orientation === \"vertical\";\n const isRtl = context.dir === \"rtl\";\n\n const dimensions = useResizeObserverStore(\n context.rootRef,\n context.contentRef,\n context.orientation,\n );\n\n React.useEffect(() => {\n if (context.rootRef.current && context.contentRef.current) {\n resizeObserverStore.observe(\n context.rootRef.current,\n context.contentRef.current,\n );\n\n return () => {\n resizeObserverStore.unobserve(\n context.rootRef.current,\n context.contentRef.current,\n );\n };\n }\n }, [context.rootRef, context.contentRef]);\n\n const multiplier = React.useMemo(() => {\n if (!context.autoFill || !dimensions) return 1;\n\n const { rootSize, contentSize } = dimensions;\n if (contentSize === 0) return 1;\n\n return contentSize < rootSize ? Math.ceil(rootSize / contentSize) : 1;\n }, [context.autoFill, dimensions]);\n\n const onMultipliedChildrenRender = React.useCallback(\n (count: number) => {\n return Array.from({ length: Math.max(0, count) }).map((_, i) => (\n {children}\n ));\n },\n [children],\n );\n\n const style = React.useMemo(\n () => ({\n ...styleProp,\n animationDuration: \"var(--marquee-duration)\",\n animationDelay: \"var(--marquee-delay)\",\n animationIterationCount: \"var(--marquee-loop-count)\",\n animationDirection: context.reverse ? \"reverse\" : \"normal\",\n }),\n [styleProp, context.reverse],\n );\n\n const contentClassName = cn(\n marqueeContentVariants({\n side: context.side,\n dir: context.dir,\n pauseOnHover: context.pauseOnHover,\n reverse: context.reverse,\n className,\n }),\n isVertical && \"flex-col\",\n isVertical\n ? \"mb-(--marquee-gap)\"\n : isRtl\n ? \"ml-(--marquee-gap)\"\n : \"mr-(--marquee-gap)\",\n );\n\n const innerContent = (\n <>\n \n {children}\n \n {onMultipliedChildrenRender(multiplier - 1)}\n \n );\n\n const visibleElement = useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n {\n className: contentClassName,\n style,\n children: innerContent,\n },\n contentProps,\n ),\n render,\n state: {\n slot: \"marquee-content\",\n orientation: context.orientation,\n },\n });\n\n return (\n <>\n {visibleElement}\n \n {onMultipliedChildrenRender(multiplier)}\n \n \n );\n}\n\ninterface MarqueeItemProps\n extends React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nfunction MarqueeItem({ className, render, ...itemProps }: MarqueeItemProps) {\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n { className: cn(\"shrink-0\", className) },\n itemProps,\n ),\n render,\n state: { slot: \"marquee-item\" },\n });\n}\n\ninterface MarqueeEdgeProps\n extends VariantProps,\n React.ComponentProps<\"div\">,\n useRender.ComponentProps<\"div\"> {}\n\nconst marqueeEdgeVariants = cva(\"pointer-events-none absolute z-10\", {\n variants: {\n side: {\n left: \"top-0 left-0 h-full bg-linear-to-r from-background to-transparent\",\n right:\n \"top-0 right-0 h-full bg-linear-to-l from-background to-transparent\",\n top: \"top-0 left-0 w-full bg-linear-to-b from-background to-transparent\",\n bottom:\n \"bottom-0 left-0 w-full bg-linear-to-t from-background to-transparent\",\n },\n size: {\n default: \"\",\n sm: \"\",\n lg: \"\",\n },\n },\n compoundVariants: [\n {\n side: [\"left\", \"right\"],\n size: \"default\",\n className: \"w-1/4\",\n },\n {\n side: [\"left\", \"right\"],\n size: \"sm\",\n className: \"w-1/6\",\n },\n {\n side: [\"left\", \"right\"],\n size: \"lg\",\n className: \"w-1/3\",\n },\n {\n side: [\"top\", \"bottom\"],\n size: \"default\",\n className: \"h-1/4\",\n },\n {\n side: [\"top\", \"bottom\"],\n size: \"sm\",\n className: \"h-1/6\",\n },\n {\n side: [\"top\", \"bottom\"],\n size: \"lg\",\n className: \"h-1/3\",\n },\n ],\n defaultVariants: {\n size: \"default\",\n },\n});\n\nfunction MarqueeEdge({\n side,\n size,\n className,\n render,\n ...edgeProps\n}: MarqueeEdgeProps) {\n return useRender({\n defaultTagName: \"div\",\n props: mergeProps<\"div\">(\n { className: cn(marqueeEdgeVariants({ side, size, className })) },\n edgeProps,\n ),\n render,\n state: {\n slot: \"marquee-edge\",\n size: size ?? \"default\",\n },\n });\n}\n\nexport { Marquee, MarqueeContent, MarqueeEdge, MarqueeItem, type MarqueeProps };\n", "target": "" }, { "path": "lib/compose-refs.ts", "type": "registry:lib", "content": "/**\n * @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx\n */\n\nimport * as React from \"react\";\n\ntype PossibleRef = React.Ref | undefined;\n\n/**\n * Set a given ref to a given value\n * This utility takes care of different types of refs: callback refs and RefObject(s)\n */\nfunction setRef(ref: PossibleRef, value: T) {\n if (typeof ref === \"function\") {\n return ref(value);\n }\n\n if (ref !== null && ref !== undefined) {\n ref.current = value;\n }\n}\n\n/**\n * A utility to compose multiple refs together\n * Accepts callback refs and RefObject(s)\n */\nfunction composeRefs(...refs: PossibleRef[]): React.RefCallback {\n return (node) => {\n let hasCleanup = false;\n const cleanups = refs.map((ref) => {\n const cleanup = setRef(ref, node);\n if (!hasCleanup && typeof cleanup === \"function\") {\n hasCleanup = true;\n }\n return cleanup;\n });\n\n // React <19 will log an error to the console if a callback ref returns a\n // value. We don't use ref cleanups internally so this will only happen if a\n // user's ref callback returns a value, which we only expect if they are\n // using the cleanup functionality added in React 19.\n if (hasCleanup) {\n return () => {\n for (let i = 0; i < cleanups.length; i++) {\n const cleanup = cleanups[i];\n if (typeof cleanup === \"function\") {\n cleanup();\n } else {\n setRef(refs[i], null);\n }\n }\n };\n }\n };\n}\n\n/**\n * A custom hook that composes multiple refs\n * Accepts callback refs and RefObject(s)\n */\nfunction useComposedRefs(...refs: PossibleRef[]): React.RefCallback {\n // biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values\n return React.useCallback(composeRefs(...refs), refs);\n}\n\nexport { composeRefs, useComposedRefs };\n", "target": "" } ], "registryDependencies": [ "direction" ], "dependencies": [ "@base-ui/react" ], "cssVars": { "theme": { "--animate-marquee-left": "marquee-left var(--marquee-duration) linear var(--marquee-loop-count)", "--animate-marquee-right": "marquee-right var(--marquee-duration) linear var(--marquee-loop-count)", "--animate-marquee-left-rtl": "marquee-left-rtl var(--marquee-duration) linear var(--marquee-loop-count)", "--animate-marquee-right-rtl": "marquee-right-rtl var(--marquee-duration) linear var(--marquee-loop-count)", "--animate-marquee-up": "marquee-up var(--marquee-duration) linear var(--marquee-loop-count)", "--animate-marquee-down": "marquee-down var(--marquee-duration) linear var(--marquee-loop-count)" } }, "css": { "@keyframes marquee-left": { "0%": { "transform": "translateX(0%)" }, "100%": { "transform": "translateX(calc(-100% - var(--marquee-gap)))" } }, "@keyframes marquee-right": { "0%": { "transform": "translateX(calc(-100% - var(--marquee-gap)))" }, "100%": { "transform": "translateX(0%)" } }, "@keyframes marquee-up": { "0%": { "transform": "translateY(0%)" }, "100%": { "transform": "translateY(calc(-100% - var(--marquee-gap)))" } }, "@keyframes marquee-down": { "0%": { "transform": "translateY(calc(-100% - var(--marquee-gap)))" }, "100%": { "transform": "translateY(calc(-100% - var(--marquee-gap)))" } }, "@keyframes marquee-left-rtl": { "0%": { "transform": "translateX(0%)" }, "100%": { "transform": "translateX(calc(100% + var(--marquee-gap)))" } }, "@keyframes marquee-right-rtl": { "0%": { "transform": "translateX(calc(100% + var(--marquee-gap)))" }, "100%": { "transform": "translateX(0%)" } } } }