{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scrolling-banner", "title": "Scrolling Banner", "description": "Auto-scrolling latest updates notice board with heading, content, and CTA per item.", "dependencies": [ "react-markdown", "remark-gfm" ], "files": [ { "path": "registry/components/scrolling-banner/index.ts", "content": "export { ScrollingBanner } from \"./index.tsx\";\nexport type { ScrollingBannerItem, ScrollingBannerProps } from \"./types\";\n", "type": "registry:block" }, { "path": "registry/components/scrolling-banner/index.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport ReactMarkdown from \"react-markdown\";\nimport type { Components } from \"react-markdown\";\nimport remarkGfm from \"remark-gfm\";\n\nimport type { ScrollingBannerProps } from \"./types\";\n\nfunction toCssSize(value: number | string | undefined) {\n if (typeof value === \"number\") {\n return `${value}px`;\n }\n\n return value;\n}\n\nfunction normalizeMarkdownContent(value: string) {\n if (!value) return \"\";\n\n return value\n .replace(/\\\\r\\\\n/g, \"\\n\")\n .replace(/\\\\n/g, \"\\n\")\n .replace(/\\\\t/g, \"\\t\");\n}\n\nconst markdownComponents: Components = {\n p: ({ children }) => (\n

{children}

\n ),\n h1: ({ children }) => (\n

{children}

\n ),\n h2: ({ children }) => (\n

{children}

\n ),\n h3: ({ children }) => (\n

{children}

\n ),\n h4: ({ children }) => (\n

{children}

\n ),\n ul: ({ children }) => (\n \n ),\n ol: ({ children }) => (\n
    {children}
\n ),\n li: ({ children }) => (\n
  • {children}
  • \n ),\n strong: ({ children }) => (\n {children}\n ),\n hr: () =>
    ,\n};\n\nexport function ScrollingBanner({\n items,\n onShowMoreClick,\n onCtaClick,\n bannerHeading,\n className = \"\",\n maxHeight,\n maxWidth,\n autoScroll = true,\n itemDurationSeconds = 3,\n pauseOnHover = true,\n allowManualScroll = true,\n ariaLabel = \"Latest updates\",\n}: ScrollingBannerProps) {\n const CONTENT_TRUNCATE_LIMIT = 50;\n const scrollContainerRef = React.useRef(null);\n const isHoveredRef = React.useRef(false);\n const safeItems = React.useMemo(() => items.filter(Boolean), [items]);\n const [activeModalItem, setActiveModalItem] = React.useState<{\n heading: string;\n content: string;\n } | null>(null);\n\n const containerStyle: React.CSSProperties = {\n maxHeight: toCssSize(maxHeight),\n maxWidth: toCssSize(maxWidth),\n };\n\n React.useEffect(() => {\n const container = scrollContainerRef.current;\n if (!autoScroll || !container || safeItems.length === 0) {\n return;\n }\n\n let frameId = 0;\n let lastTime = 0;\n const pixelsPerSecond = Math.max(\n 8,\n 120 / Math.max(itemDurationSeconds, 0.5),\n );\n\n const tick = (time: number) => {\n if (!lastTime) {\n lastTime = time;\n }\n\n const deltaSeconds = (time - lastTime) / 1000;\n lastTime = time;\n\n if (!(pauseOnHover && isHoveredRef.current)) {\n const maxScrollTop = Math.max(\n 0,\n container.scrollHeight - container.clientHeight,\n );\n if (maxScrollTop === 0) {\n frameId = window.requestAnimationFrame(tick);\n return;\n }\n\n container.scrollTop += pixelsPerSecond * deltaSeconds;\n if (container.scrollTop >= maxScrollTop) {\n container.scrollTop = 0;\n }\n }\n\n frameId = window.requestAnimationFrame(tick);\n };\n\n frameId = window.requestAnimationFrame(tick);\n\n return () => {\n window.cancelAnimationFrame(frameId);\n };\n }, [autoScroll, safeItems.length, itemDurationSeconds, pauseOnHover]);\n\n if (!safeItems.length) {\n return (\n \n No latest updates available.\n \n );\n }\n\n return (\n \n {bannerHeading && bannerHeading.length > 0 ? (\n
    \n

    \n {bannerHeading}\n

    \n
    \n ) : null}\n {\n isHoveredRef.current = true;\n }}\n onMouseLeave={() => {\n isHoveredRef.current = false;\n }}\n >\n
    \n \n {safeItems.map((item, index) => (\n (() => {\n const itemKey = `${item.id ?? item.heading ?? `item-${index}`}-${index}`;\n const contentText = normalizeMarkdownContent(item.content ?? \"\");\n const hasLongContent = contentText.length > CONTENT_TRUNCATE_LIMIT;\n const hasCtaText = (item.ctaText ?? \"\").trim().length > 0;\n const hasCtaLink = (item.ctaLink ?? \"\").trim().length > 0;\n\n return (\n \n
    \n \n
    \n
    \n

    \n {item.heading}\n

    \n \n \n {contentText}\n \n
    \n {hasLongContent ? (\n {\n onShowMoreClick?.(item);\n setActiveModalItem({\n heading: item.heading,\n content: contentText,\n });\n }}\n >\n View more\n \n ) : null}\n {hasCtaText && hasCtaLink ? (\n {\n onCtaClick?.(item);\n }}\n className=\"inline-flex max-w-full min-w-0 items-center break-words rounded-[8px] bg-[#EF8833] px-3 py-[6px] text-center text-[12px] font-[500] leading-[18px] text-white transition-colors hover:bg-[#DC7A2D]\"\n >\n {item.ctaText}\n \n ) : null}\n
    \n \n );\n })()\n ))}\n \n \n {activeModalItem ? (\n {\n setActiveModalItem(null);\n }}\n >\n {\n event.stopPropagation();\n }}\n >\n
    \n

    \n {activeModalItem.heading}\n

    \n {\n setActiveModalItem(null);\n }}\n >\n Close\n \n
    \n
    \n \n {activeModalItem.content}\n \n
    \n \n \n ) : null}\n \n \n );\n}\n\nexport type { ScrollingBannerItem, ScrollingBannerProps } from \"./types\";\n", "type": "registry:block" }, { "path": "registry/components/scrolling-banner/types.ts", "content": "export type ScrollingBannerItem = {\n id?: string;\n heading: string;\n content: string;\n ctaText?: string;\n ctaLink?: string;\n openInNewTab?: boolean;\n};\n\nexport type ScrollingBannerProps = {\n items: ScrollingBannerItem[];\n onShowMoreClick?: (item: ScrollingBannerItem) => void;\n onCtaClick?: (item: ScrollingBannerItem) => void;\n bannerHeading?: string;\n className?: string;\n maxHeight?: number | string;\n maxWidth?: number | string;\n autoScroll?: boolean;\n itemDurationSeconds?: number;\n pauseOnHover?: boolean;\n allowManualScroll?: boolean;\n ariaLabel?: string;\n};\n", "type": "registry:block" } ], "type": "registry:block" }