{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infiniteribbon",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": [],
  "devDependencies": [],
  "files": [
    {
      "path": "infiniteribbon.tsx",
      "content": "\"use client\";\n\nimport {\n  type CSSProperties,\n  forwardRef,\n  type HTMLAttributes,\n  type ReactNode,\n  useCallback,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst componentThemeClassName =\n  \"[--ic-background:#ffffff] [--ic-foreground:#111111] [--ic-primary:#111111] [--ic-secondary:#646b75] [--ic-surface-border:#e9edf2] [--ic-border:#e3e7ec] [--ic-card:#ffffff] [--ic-card-foreground:#111111] [--ic-muted:#f5f7fa] [--ic-muted-foreground:#6d7480] [--ic-accent:#f3f5f8] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--ic-accent-foreground:#111111] [--ic-input:#e3e7ec] [--ic-ring:rgba(17,17,17,0.16)] [--ic-destructive:#dc2626] [--ic-paper:#fcfcfd] [--ic-popover-foreground:#111111] [--ic-brand:#0ea5e9] [--ic-brand-soft:#bae6fd] [--ic-shadow-soft:0_18px_38px_-24px_rgba(15,23,42,0.35)] [--ic-chart-1:oklch(0.52_0.19_254)] [--ic-chart-2:oklch(0.74_0.11_232)] [--ic-chart-3:oklch(0.42_0.16_262)] [--ic-chart-4:oklch(0.84_0.07_228)] [--ic-chart-5:oklch(0.62_0.14_240)] [--color-background:var(--ic-background)] [--color-foreground:var(--ic-foreground)] [--color-primary:var(--ic-primary)] [--color-secondary:var(--ic-secondary)] [--color-border:var(--ic-border)] [--color-card:var(--ic-card)] [--color-card-foreground:var(--ic-card-foreground)] [--color-muted:var(--ic-muted)] [--color-muted-foreground:var(--ic-muted-foreground)] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] [--color-input:var(--ic-input)] [--color-ring:var(--ic-ring)] [--color-destructive:var(--ic-destructive)] [--color-paper:var(--ic-paper)] [--color-popover-foreground:var(--ic-popover-foreground)] [--color-brand:var(--ic-brand)] [--color-brand-soft:var(--ic-brand-soft)] [--color-chart-1:var(--ic-chart-1)] [--color-chart-2:var(--ic-chart-2)] [--color-chart-3:var(--ic-chart-3)] [--color-chart-4:var(--ic-chart-4)] [--color-chart-5:var(--ic-chart-5)] [--ribbon-warning-bg:#fef3c7] [--ribbon-warning-fg:#78350f] dark:[--ic-background:#111111] dark:[--ic-foreground:#f6f3ec] dark:[--ic-primary:#f6f3ec] dark:[--ic-secondary:#cbc6bb] dark:[--ic-surface-border:#2a2a25] dark:[--ic-border:#2b2a25] dark:[--ic-card:#111111] dark:[--ic-card-foreground:#f6f3ec] dark:[--ic-muted:#171716] dark:[--ic-muted-foreground:#9a958a] dark:[--ic-accent:#1a1a18] [--color-accent:var(--ic-accent)] [--color-accent-foreground:var(--ic-accent-foreground)] dark:[--ic-accent-foreground:#f6f3ec] dark:[--ic-input:#2b2a25] dark:[--ic-ring:rgba(246,243,236,0.18)] dark:[--ic-destructive:#f87171] dark:[--ic-paper:#171716] dark:[--ic-popover-foreground:#f6f3ec] dark:[--ic-brand:#38bdf8] dark:[--ic-brand-soft:#0c4a6e] dark:[--ic-shadow-soft:0_20px_44px_-28px_rgba(0,0,0,0.6)] dark:[--ic-chart-1:oklch(0.68_0.17_250)] dark:[--ic-chart-2:oklch(0.82_0.09_225)] dark:[--ic-chart-3:oklch(0.58_0.15_260)] dark:[--ic-chart-4:oklch(0.75_0.12_235)] dark:[--ic-chart-5:oklch(0.88_0.06_220)] dark:[--ribbon-warning-bg:#422006] dark:[--ribbon-warning-fg:#fde68a]\";\n\nconst STYLE_ID = \"iconiq-infinite-ribbon-styles\";\nconst REDUCED_MOTION_QUERY = \"(prefers-reduced-motion: reduce)\";\nconst DEFAULT_GAP = \"2rem\";\nconst DEFAULT_REPEAT = 5;\nconst DEFAULT_DURATION = 10;\nconst MIN_DURATION = 0.1;\nconst MIN_REPEAT_BUFFER = 2;\nconst SEGMENT_CLASS =\n  \"inline-flex shrink-0 items-center whitespace-nowrap pr-[var(--ribbon-gap)]\";\n\nconst RIBBON_CSS = `\n@keyframes iconiq-infinite-ribbon-x {\n  from {\n    transform: translate3d(0, 0, 0);\n  }\n  to {\n    transform: translate3d(-50%, 0, 0);\n  }\n}\n\n@keyframes iconiq-infinite-ribbon-x-reverse {\n  from {\n    transform: translate3d(-50%, 0, 0);\n  }\n  to {\n    transform: translate3d(0, 0, 0);\n  }\n}\n\n.iconiq-infinite-ribbon-track {\n  animation-duration: var(--ribbon-duration, 10s);\n  animation-timing-function: linear;\n  animation-iteration-count: infinite;\n}\n\n.iconiq-infinite-ribbon-track[data-animated=\"true\"] {\n  will-change: transform;\n}\n\n.iconiq-infinite-ribbon-track[data-direction=\"forward\"] {\n  animation-name: iconiq-infinite-ribbon-x;\n}\n\n.iconiq-infinite-ribbon-track[data-direction=\"reverse\"] {\n  animation-name: iconiq-infinite-ribbon-x-reverse;\n}\n\n.iconiq-infinite-ribbon-root[data-pause-on-hover=\"true\"]:hover .iconiq-infinite-ribbon-track,\n.iconiq-infinite-ribbon-root[data-pause-on-hover=\"true\"]:focus-within .iconiq-infinite-ribbon-track,\n.iconiq-infinite-ribbon-track[data-paused=\"true\"] {\n  animation-play-state: paused;\n}\n\n.iconiq-infinite-ribbon-viewport[data-fade-edges=\"true\"] {\n  -webkit-mask-image: linear-gradient(\n    to right,\n    transparent 0%,\n    #000 8%,\n    #000 92%,\n    transparent 100%\n  );\n  mask-image: linear-gradient(\n    to right,\n    transparent 0%,\n    #000 8%,\n    #000 92%,\n    transparent 100%\n  );\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .iconiq-infinite-ribbon-track {\n    animation: none !important;\n    transform: none !important;\n  }\n}\n`;\n\nconst linkClassName =\n  \"underline decoration-current/40 underline-offset-2 transition-colors hover:decoration-current\";\n\ntype InfiniteRibbonVariant = \"default\" | \"brand\" | \"warning\";\n\nconst variantClassNames = {\n  default:\n    \"bg-[#f3f4f6] text-neutral-900 [--ribbon-link:#111111] dark:bg-muted dark:text-foreground dark:[--ribbon-link:var(--ic-foreground)]\",\n  brand:\n    \"bg-[var(--ic-brand)] text-white [--ribbon-link:#ffffff] dark:bg-[var(--ic-brand)] dark:text-white\",\n  warning:\n    \"bg-[var(--ribbon-warning-bg)] text-[var(--ribbon-warning-fg)] [--ribbon-link:var(--ribbon-warning-fg)]\",\n} as const satisfies Record<InfiniteRibbonVariant, string>;\n\nexport interface InfiniteRibbonProps\n  extends Omit<HTMLAttributes<HTMLDivElement>, \"children\"> {\n  children?: ReactNode;\n  items?: string[];\n  separator?: ReactNode;\n  repeat?: number;\n  duration?: number;\n  speed?: number;\n  reverse?: boolean;\n  rotation?: number;\n  variant?: InfiniteRibbonVariant;\n  gap?: number | string;\n  pauseOnHover?: boolean;\n  pauseWhenHidden?: boolean;\n  fadeEdges?: boolean;\n  selectable?: boolean;\n  href?: string;\n  interactive?: boolean;\n  \"aria-label\"?: string;\n}\n\nfunction ensureRibbonStyles() {\n  if (typeof document === \"undefined\") {\n    return;\n  }\n\n  let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;\n\n  if (!style) {\n    style = document.createElement(\"style\");\n    style.id = STYLE_ID;\n    document.head.appendChild(style);\n  }\n\n  if (style.textContent !== RIBBON_CSS) {\n    style.textContent = RIBBON_CSS;\n  }\n}\n\nfunction subscribeReducedMotion(onStoreChange: () => void) {\n  const media = window.matchMedia(REDUCED_MOTION_QUERY);\n  media.addEventListener(\"change\", onStoreChange);\n\n  return () => {\n    media.removeEventListener(\"change\", onStoreChange);\n  };\n}\n\nfunction getReducedMotionSnapshot() {\n  return window.matchMedia(REDUCED_MOTION_QUERY).matches;\n}\n\nfunction getReducedMotionServerSnapshot() {\n  return false;\n}\n\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    getReducedMotionSnapshot,\n    getReducedMotionServerSnapshot\n  );\n}\n\nfunction clampRepeat(value: number) {\n  if (!Number.isFinite(value)) {\n    return DEFAULT_REPEAT;\n  }\n\n  return Math.max(1, Math.floor(value));\n}\n\nfunction clampDuration(value: number) {\n  if (!Number.isFinite(value)) {\n    return DEFAULT_DURATION;\n  }\n\n  return Math.max(MIN_DURATION, value);\n}\n\nfunction clampSpeed(value: number | undefined) {\n  if (value === undefined || !Number.isFinite(value) || value <= 0) {\n    return undefined;\n  }\n\n  return value;\n}\n\nfunction clampRotation(value: number) {\n  return Number.isFinite(value) ? value : 0;\n}\n\nfunction resolveGap(gap?: number | string) {\n  if (gap === undefined) {\n    return DEFAULT_GAP;\n  }\n\n  if (typeof gap === \"number\") {\n    return Number.isFinite(gap) ? `${gap}px` : DEFAULT_GAP;\n  }\n\n  return gap.trim() || DEFAULT_GAP;\n}\n\nfunction normalizeItems(items?: string[]) {\n  if (!items?.length) {\n    return [];\n  }\n\n  return items.map((item) => item.trim()).filter(Boolean);\n}\n\nfunction nodeToPlainText(node: ReactNode): string {\n  if (typeof node === \"string\" || typeof node === \"number\") {\n    return String(node);\n  }\n\n  if (Array.isArray(node)) {\n    return node.map(nodeToPlainText).join(\"\");\n  }\n\n  return \"\";\n}\n\nfunction hasRibbonContent(items: string[], children?: ReactNode) {\n  if (items.length > 0) {\n    return true;\n  }\n\n  return nodeToPlainText(children).trim().length > 0;\n}\n\nfunction buildItemsContent(\n  items: string[],\n  separator: ReactNode,\n  children: ReactNode | undefined\n) {\n  if (items.length > 0) {\n    return items.map((item, index) => (\n      <span className=\"inline-flex items-center\" key={`${item}-${index}`}>\n        {index > 0 ? (\n          <span aria-hidden className=\"mx-[calc(var(--ribbon-gap)/2)]\">\n            {separator}\n          </span>\n        ) : null}\n        {item}\n      </span>\n    ));\n  }\n\n  return children;\n}\n\nfunction getAccessibleLabel({\n  ariaLabel,\n  items,\n  children,\n}: {\n  ariaLabel?: string;\n  items: string[];\n  children?: ReactNode;\n}) {\n  if (ariaLabel) {\n    return ariaLabel;\n  }\n\n  if (items.length > 0) {\n    return items.join(\", \");\n  }\n\n  const text = nodeToPlainText(children).trim();\n  return text || \"Announcement\";\n}\n\nconst InfiniteRibbon = forwardRef<HTMLDivElement, InfiniteRibbonProps>(\n  function InfiniteRibbon(\n    {\n      children,\n      items,\n      separator = \" · \",\n      repeat = DEFAULT_REPEAT,\n      duration = DEFAULT_DURATION,\n      speed,\n      reverse = false,\n      rotation = 0,\n      variant = \"default\",\n      gap,\n      pauseOnHover = true,\n      pauseWhenHidden = true,\n      fadeEdges = false,\n      selectable = true,\n      href,\n      interactive,\n      \"aria-label\": ariaLabel,\n      className,\n      style,\n      ...props\n    },\n    ref\n  ) {\n    const viewportRef = useRef<HTMLDivElement | null>(null);\n    const probeRef = useRef<HTMLSpanElement | null>(null);\n    const trackRef = useRef<HTMLDivElement | null>(null);\n    const measureFrameRef = useRef<number | null>(null);\n\n    const resolvedRepeat = clampRepeat(repeat);\n    const resolvedDuration = clampDuration(duration);\n    const resolvedSpeed = clampSpeed(speed);\n    const resolvedRotation = clampRotation(rotation);\n    const normalizedItems = useMemo(() => normalizeItems(items), [items]);\n    const resolvedGap = resolveGap(gap);\n    const segmentContent = useMemo(\n      () => buildItemsContent(normalizedItems, separator, children),\n      [children, normalizedItems, separator]\n    );\n    const hasContent = hasRibbonContent(normalizedItems, children);\n    const accessibleLabel = getAccessibleLabel({\n      ariaLabel,\n      items: normalizedItems,\n      children,\n    });\n    const reduceMotion = usePrefersReducedMotion();\n    const isInteractive = interactive ?? Boolean(href);\n    const direction = reverse ? \"reverse\" : \"forward\";\n    const shouldAnimate = !reduceMotion;\n\n    const [computedRepeat, setComputedRepeat] = useState(resolvedRepeat);\n    const [loopDuration, setLoopDuration] = useState(resolvedDuration);\n    const [hiddenPaused, setHiddenPaused] = useState(false);\n\n    const setWrapperRef = useCallback(\n      (node: HTMLDivElement | null) => {\n        if (typeof ref === \"function\") {\n          ref(node);\n        } else if (ref) {\n          ref.current = node;\n        }\n      },\n      [ref]\n    );\n\n    const layoutKey = `${resolvedGap}:${normalizedItems.join(\"\\u0000\")}:${nodeToPlainText(children)}`;\n\n    const measureLayout = useCallback(() => {\n      const viewport = viewportRef.current;\n      const probe = probeRef.current;\n      const track = trackRef.current;\n\n      if (!(viewport && probe)) {\n        return;\n      }\n\n      const viewportWidth = viewport.clientWidth;\n      const segmentWidth = probe.offsetWidth;\n\n      if (viewportWidth > 0 && segmentWidth > 0) {\n        const minPerHalf =\n          Math.ceil(viewportWidth / segmentWidth) + MIN_REPEAT_BUFFER;\n\n        setComputedRepeat((current) => {\n          const next = Math.max(resolvedRepeat, minPerHalf);\n          return next === current ? current : next;\n        });\n      }\n\n      if (track) {\n        const halfTravel = track.scrollWidth / 2;\n\n        if (resolvedSpeed && halfTravel > 0) {\n          const nextDuration = Math.max(\n            MIN_DURATION,\n            halfTravel / resolvedSpeed\n          );\n          setLoopDuration((current) =>\n            Math.abs(current - nextDuration) < 0.01 ? current : nextDuration\n          );\n          return;\n        }\n      }\n\n      setLoopDuration((current) =>\n        current === resolvedDuration ? current : resolvedDuration\n      );\n    }, [resolvedDuration, resolvedRepeat, resolvedSpeed]);\n\n    const scheduleMeasure = useCallback(() => {\n      if (measureFrameRef.current !== null) {\n        cancelAnimationFrame(measureFrameRef.current);\n      }\n\n      measureFrameRef.current = requestAnimationFrame(() => {\n        measureFrameRef.current = null;\n        measureLayout();\n      });\n    }, [measureLayout]);\n\n    useLayoutEffect(() => {\n      ensureRibbonStyles();\n    }, []);\n\n    useLayoutEffect(() => {\n      setComputedRepeat(resolvedRepeat);\n    }, [resolvedRepeat]);\n\n    // biome-ignore lint/correctness/useExhaustiveDependencies: layoutKey triggers remeasure when ribbon copy or spacing changes.\n    useLayoutEffect(() => {\n      if (!(hasContent && shouldAnimate)) {\n        return;\n      }\n\n      scheduleMeasure();\n    }, [hasContent, layoutKey, scheduleMeasure, shouldAnimate]);\n\n    useLayoutEffect(() => {\n      if (!(hasContent && shouldAnimate)) {\n        return;\n      }\n\n      const viewport = viewportRef.current;\n      if (!viewport) {\n        return;\n      }\n\n      const observer = new ResizeObserver(() => {\n        scheduleMeasure();\n      });\n\n      observer.observe(viewport);\n      return () => {\n        observer.disconnect();\n      };\n    }, [hasContent, scheduleMeasure, shouldAnimate]);\n\n    useLayoutEffect(() => {\n      return () => {\n        if (measureFrameRef.current !== null) {\n          cancelAnimationFrame(measureFrameRef.current);\n        }\n      };\n    }, []);\n\n    useLayoutEffect(() => {\n      if (!(pauseWhenHidden && shouldAnimate)) {\n        setHiddenPaused(false);\n        return;\n      }\n\n      const syncHiddenState = () => {\n        setHiddenPaused(document.hidden);\n      };\n\n      syncHiddenState();\n      document.addEventListener(\"visibilitychange\", syncHiddenState);\n\n      return () => {\n        document.removeEventListener(\"visibilitychange\", syncHiddenState);\n      };\n    }, [pauseWhenHidden, shouldAnimate]);\n\n    const trackStyle = useMemo(\n      () =>\n        ({\n          \"--ribbon-duration\": `${loopDuration}s`,\n          \"--ribbon-gap\": resolvedGap,\n        }) as CSSProperties,\n      [loopDuration, resolvedGap]\n    );\n\n    const renderSegmentBody = (index: number) => {\n      if (href) {\n        if (index > 0) {\n          return (\n            <span aria-hidden className={linkClassName}>\n              {segmentContent}\n            </span>\n          );\n        }\n\n        return (\n          <a className={linkClassName} href={href}>\n            {segmentContent}\n          </a>\n        );\n      }\n\n      return segmentContent;\n    };\n\n    const isDuplicateSegment = (index: number) =>\n      isInteractive && index > 0 && !href;\n\n    const trackSegments = Array.from(\n      { length: computedRepeat * 2 },\n      (_, index) => (\n        <span\n          aria-hidden={isDuplicateSegment(index) ? true : undefined}\n          className={cn(SEGMENT_CLASS, !selectable && \"select-none\")}\n          key={`ribbon-segment-${index}`}\n        >\n          {renderSegmentBody(index)}\n        </span>\n      )\n    );\n\n    if (!hasContent) {\n      return null;\n    }\n\n    const staticContent = (\n      <div className=\"flex w-full items-center justify-center px-4 py-1.5 text-center text-sm\">\n        {href ? (\n          <a className={linkClassName} href={href}>\n            {segmentContent}\n          </a>\n        ) : (\n          segmentContent\n        )}\n      </div>\n    );\n\n    const animatedContent = (\n      <>\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 left-0 opacity-0\"\n          ref={probeRef}\n        >\n          <span className={SEGMENT_CLASS} style={trackStyle}>\n            {renderSegmentBody(0)}\n          </span>\n        </span>\n\n        <div\n          aria-hidden={isInteractive ? undefined : true}\n          className=\"iconiq-infinite-ribbon-track flex w-max flex-row\"\n          data-animated={shouldAnimate ? \"true\" : \"false\"}\n          data-direction={direction}\n          data-paused={hiddenPaused ? \"true\" : \"false\"}\n          key={`${computedRepeat}-${direction}-${resolvedGap}`}\n          ref={trackRef}\n          style={trackStyle}\n        >\n          {trackSegments}\n        </div>\n      </>\n    );\n\n    return (\n      <div\n        className={cn(\n          \"iconiq-infinite-ribbon-rotate-wrapper w-full max-w-full origin-center\",\n          className\n        )}\n        ref={setWrapperRef}\n        style={{\n          ...style,\n          transform:\n            resolvedRotation !== 0\n              ? style?.transform\n                ? `${style.transform} rotate(${resolvedRotation}deg)`\n                : `rotate(${resolvedRotation}deg)`\n              : style?.transform,\n        }}\n      >\n        <section\n          aria-label={accessibleLabel}\n          className={cn(\n            componentThemeClassName,\n            \"iconiq-infinite-ribbon-root w-full max-w-full text-sm\",\n            variantClassNames[variant]\n          )}\n          data-pause-on-hover={pauseOnHover && shouldAnimate ? \"true\" : \"false\"}\n          {...props}\n        >\n          {isInteractive ? null : (\n            <span className=\"sr-only\">{segmentContent}</span>\n          )}\n\n          <div\n            className=\"iconiq-infinite-ribbon-viewport relative w-full overflow-hidden py-1.5\"\n            data-fade-edges={fadeEdges ? \"true\" : \"false\"}\n            ref={viewportRef}\n          >\n            {shouldAnimate ? animatedContent : staticContent}\n          </div>\n        </section>\n      </div>\n    );\n  }\n);\n\nInfiniteRibbon.displayName = \"InfiniteRibbon\";\n\nexport { InfiniteRibbon };\n",
      "type": "registry:ui"
    }
  ],
  "title": "Infinite Ribbon",
  "description": "Full-width looping announcement ribbon with repeated content, optional reverse direction, and diagonal rotation controls."
}
