{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "LogoLoop-JS-TW",
	"title": "LogoLoop",
	"description": "Continuously looping marquee of brand or tech logos with seamless repeat and hover pause.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "LogoLoop/LogoLoop.jsx",
			"content": "import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react';\n\nconst ANIMATION_CONFIG = {\n  SMOOTH_TAU: 0.25,\n  MIN_COPIES: 2,\n  COPY_HEADROOM: 2\n};\n\nconst toCssLength = value => (typeof value === 'number' ? `${value}px` : (value ?? undefined));\n\nconst cx = (...parts) => parts.filter(Boolean).join(' ');\n\nconst useResizeObserver = (callback, elements, dependencies) => {\n  useEffect(() => {\n    if (!window.ResizeObserver) {\n      const handleResize = () => callback();\n      window.addEventListener('resize', handleResize);\n      callback();\n      return () => window.removeEventListener('resize', handleResize);\n    }\n\n    const observers = elements.map(ref => {\n      if (!ref.current) return null;\n      const observer = new ResizeObserver(callback);\n      observer.observe(ref.current);\n      return observer;\n    });\n\n    callback();\n    return () => {\n      observers.forEach(observer => observer?.disconnect());\n    };\n  }, [callback, elements, dependencies]);\n};\n\nconst useImageLoader = (seqRef, onLoad, dependencies) => {\n  useEffect(() => {\n    const images = seqRef.current?.querySelectorAll('img') ?? [];\n\n    if (images.length === 0) {\n      onLoad();\n      return;\n    }\n\n    let remainingImages = images.length;\n    const handleImageLoad = () => {\n      remainingImages -= 1;\n      if (remainingImages === 0) {\n        onLoad();\n      }\n    };\n\n    images.forEach(img => {\n      const htmlImg = img;\n      if (htmlImg.complete) {\n        handleImageLoad();\n      } else {\n        htmlImg.addEventListener('load', handleImageLoad, { once: true });\n        htmlImg.addEventListener('error', handleImageLoad, { once: true });\n      }\n    });\n\n    return () => {\n      images.forEach(img => {\n        img.removeEventListener('load', handleImageLoad);\n        img.removeEventListener('error', handleImageLoad);\n      });\n    };\n  }, [onLoad, seqRef, dependencies]);\n};\n\nconst useAnimationLoop = (trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) => {\n  const rafRef = useRef(null);\n  const lastTimestampRef = useRef(null);\n  const offsetRef = useRef(0);\n  const velocityRef = useRef(0);\n\n  useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n\n    const prefersReduced =\n      typeof window !== 'undefined' &&\n      window.matchMedia &&\n      window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\n    const seqSize = isVertical ? seqHeight : seqWidth;\n\n    if (seqSize > 0) {\n      offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;\n      const transformValue = isVertical\n        ? `translate3d(0, ${-offsetRef.current}px, 0)`\n        : `translate3d(${-offsetRef.current}px, 0, 0)`;\n      track.style.transform = transformValue;\n    }\n\n    if (prefersReduced) {\n      track.style.transform = isVertical ? 'translate3d(0, 0, 0)' : 'translate3d(0, 0, 0)';\n      return () => {\n        lastTimestampRef.current = null;\n      };\n    }\n\n    const animate = timestamp => {\n      if (lastTimestampRef.current === null) {\n        lastTimestampRef.current = timestamp;\n      }\n\n      const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000;\n      lastTimestampRef.current = timestamp;\n\n      const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;\n\n      const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n      velocityRef.current += (target - velocityRef.current) * easingFactor;\n\n      if (seqSize > 0) {\n        let nextOffset = offsetRef.current + velocityRef.current * deltaTime;\n        nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;\n        offsetRef.current = nextOffset;\n\n        const transformValue = isVertical\n          ? `translate3d(0, ${-offsetRef.current}px, 0)`\n          : `translate3d(${-offsetRef.current}px, 0, 0)`;\n        track.style.transform = transformValue;\n      }\n\n      rafRef.current = requestAnimationFrame(animate);\n    };\n\n    rafRef.current = requestAnimationFrame(animate);\n\n    return () => {\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n      lastTimestampRef.current = null;\n    };\n  }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical, trackRef]);\n};\n\nexport const LogoLoop = memo(\n  ({\n    logos,\n    speed = 120,\n    direction = 'left',\n    width = '100%',\n    logoHeight = 28,\n    gap = 32,\n    pauseOnHover,\n    hoverSpeed,\n    fadeOut = false,\n    fadeOutColor,\n    scaleOnHover = false,\n    renderItem,\n    ariaLabel = 'Partner logos',\n    className,\n    style\n  }) => {\n    const containerRef = useRef(null);\n    const trackRef = useRef(null);\n    const seqRef = useRef(null);\n\n    const [seqWidth, setSeqWidth] = useState(0);\n    const [seqHeight, setSeqHeight] = useState(0);\n    const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);\n    const [isHovered, setIsHovered] = useState(false);\n\n    const effectiveHoverSpeed = useMemo(() => {\n      if (hoverSpeed !== undefined) return hoverSpeed;\n      if (pauseOnHover === true) return 0;\n      if (pauseOnHover === false) return undefined;\n      return 0;\n    }, [hoverSpeed, pauseOnHover]);\n\n    const isVertical = direction === 'up' || direction === 'down';\n\n    const targetVelocity = useMemo(() => {\n      const magnitude = Math.abs(speed);\n      let directionMultiplier;\n      if (isVertical) {\n        directionMultiplier = direction === 'up' ? 1 : -1;\n      } else {\n        directionMultiplier = direction === 'left' ? 1 : -1;\n      }\n      const speedMultiplier = speed < 0 ? -1 : 1;\n      return magnitude * directionMultiplier * speedMultiplier;\n    }, [speed, direction, isVertical]);\n\n    const updateDimensions = useCallback(() => {\n      const containerWidth = containerRef.current?.clientWidth ?? 0;\n      const sequenceRect = seqRef.current?.getBoundingClientRect?.();\n      const sequenceWidth = sequenceRect?.width ?? 0;\n      const sequenceHeight = sequenceRect?.height ?? 0;\n      if (isVertical) {\n        const parentHeight = containerRef.current?.parentElement?.clientHeight ?? 0;\n        if (containerRef.current && parentHeight > 0) {\n          const targetHeight = Math.ceil(parentHeight);\n          if (containerRef.current.style.height !== `${targetHeight}px`)\n            containerRef.current.style.height = `${targetHeight}px`;\n        }\n        if (sequenceHeight > 0) {\n          setSeqHeight(Math.ceil(sequenceHeight));\n          const viewport = containerRef.current?.clientHeight ?? parentHeight ?? sequenceHeight;\n          const copiesNeeded = Math.ceil(viewport / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM;\n          setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n        }\n      } else if (sequenceWidth > 0) {\n        setSeqWidth(Math.ceil(sequenceWidth));\n        const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM;\n        setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n      }\n    }, [isVertical]);\n\n    useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);\n\n    useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);\n\n    useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);\n\n    const cssVariables = useMemo(\n      () => ({\n        '--logoloop-gap': `${gap}px`,\n        '--logoloop-logoHeight': `${logoHeight}px`,\n        ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })\n      }),\n      [gap, logoHeight, fadeOutColor]\n    );\n\n    const rootClasses = useMemo(\n      () =>\n        cx(\n          'relative group',\n          isVertical ? 'overflow-hidden h-full inline-block' : 'overflow-x-hidden',\n          '[--logoloop-gap:32px]',\n          '[--logoloop-logoHeight:28px]',\n          '[--logoloop-fadeColorAuto:#ffffff]',\n          'dark:[--logoloop-fadeColorAuto:#0b0b0b]',\n          scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]',\n          className\n        ),\n      [isVertical, scaleOnHover, className]\n    );\n\n    const handleMouseEnter = useCallback(() => {\n      if (effectiveHoverSpeed !== undefined) setIsHovered(true);\n    }, [effectiveHoverSpeed]);\n    const handleMouseLeave = useCallback(() => {\n      if (effectiveHoverSpeed !== undefined) setIsHovered(false);\n    }, [effectiveHoverSpeed]);\n\n    const renderLogoItem = useCallback(\n      (item, key) => {\n        if (renderItem) {\n          return (\n            <li\n              className={cx(\n                'flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]',\n                isVertical ? 'mb-[var(--logoloop-gap)]' : 'mr-[var(--logoloop-gap)]',\n                scaleOnHover && 'overflow-visible group/item'\n              )}\n              key={key}\n              role=\"listitem\"\n            >\n              {renderItem(item, key)}\n            </li>\n          );\n        }\n\n        const isNodeItem = 'node' in item;\n\n        const content = isNodeItem ? (\n          <span\n            className={cx(\n              'inline-flex items-center',\n              'motion-reduce:transition-none',\n              scaleOnHover &&\n                'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'\n            )}\n            aria-hidden={!!item.href && !item.ariaLabel}\n          >\n            {item.node}\n          </span>\n        ) : (\n          <img\n            className={cx(\n              'h-[var(--logoloop-logoHeight)] w-auto block object-contain',\n              '[-webkit-user-drag:none] pointer-events-none',\n              '[image-rendering:-webkit-optimize-contrast]',\n              'motion-reduce:transition-none',\n              scaleOnHover &&\n                'transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120'\n            )}\n            src={item.src}\n            srcSet={item.srcSet}\n            sizes={item.sizes}\n            width={item.width}\n            height={item.height}\n            alt={item.alt ?? ''}\n            title={item.title}\n            loading=\"lazy\"\n            decoding=\"async\"\n            draggable={false}\n          />\n        );\n\n        const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n        const inner = item.href ? (\n          <a\n            className={cx(\n              'inline-flex items-center no-underline rounded',\n              'transition-opacity duration-200 ease-linear',\n              'hover:opacity-80',\n              'focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2'\n            )}\n            href={item.href}\n            aria-label={itemAriaLabel || 'logo link'}\n            target=\"_blank\"\n            rel=\"noreferrer noopener\"\n          >\n            {content}\n          </a>\n        ) : (\n          content\n        );\n\n        return (\n          <li\n            className={cx(\n              'flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]',\n              isVertical ? 'mb-[var(--logoloop-gap)]' : 'mr-[var(--logoloop-gap)]',\n              scaleOnHover && 'overflow-visible group/item'\n            )}\n            key={key}\n            role=\"listitem\"\n          >\n            {inner}\n          </li>\n        );\n      },\n      [isVertical, scaleOnHover, renderItem]\n    );\n\n    const logoLists = useMemo(\n      () =>\n        Array.from({ length: copyCount }, (_, copyIndex) => (\n          <ul\n            className={cx('flex items-center', isVertical && 'flex-col')}\n            key={`copy-${copyIndex}`}\n            role=\"list\"\n            aria-hidden={copyIndex > 0}\n            ref={copyIndex === 0 ? seqRef : undefined}\n          >\n            {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n          </ul>\n        )),\n      [copyCount, logos, renderLogoItem, isVertical]\n    );\n\n    const containerStyle = useMemo(\n      () => ({\n        width: isVertical\n          ? toCssLength(width) === '100%'\n            ? undefined\n            : toCssLength(width)\n          : (toCssLength(width) ?? '100%'),\n        ...cssVariables,\n        ...style\n      }),\n      [width, cssVariables, style, isVertical]\n    );\n\n    return (\n      <div\n        ref={containerRef}\n        className={rootClasses}\n        style={containerStyle}\n        role=\"region\"\n        aria-label={ariaLabel}\n        onMouseEnter={handleMouseEnter}\n        onMouseLeave={handleMouseLeave}\n      >\n        {fadeOut && (\n          <>\n            {isVertical ? (\n              <>\n                <div\n                  aria-hidden\n                  className={cx(\n                    'pointer-events-none absolute inset-x-0 top-0 z-10',\n                    'h-[clamp(24px,8%,120px)]',\n                    'bg-[linear-gradient(to_bottom,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n                  )}\n                />\n                <div\n                  aria-hidden\n                  className={cx(\n                    'pointer-events-none absolute inset-x-0 bottom-0 z-10',\n                    'h-[clamp(24px,8%,120px)]',\n                    'bg-[linear-gradient(to_top,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n                  )}\n                />\n              </>\n            ) : (\n              <>\n                <div\n                  aria-hidden\n                  className={cx(\n                    'pointer-events-none absolute inset-y-0 left-0 z-10',\n                    'w-[clamp(24px,8%,120px)]',\n                    'bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n                  )}\n                />\n                <div\n                  aria-hidden\n                  className={cx(\n                    'pointer-events-none absolute inset-y-0 right-0 z-10',\n                    'w-[clamp(24px,8%,120px)]',\n                    'bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]'\n                  )}\n                />\n              </>\n            )}\n          </>\n        )}\n\n        <div\n          className={cx(\n            'flex will-change-transform select-none relative z-0',\n            'motion-reduce:transform-none',\n            isVertical ? 'flex-col h-max w-full' : 'flex-row w-max'\n          )}\n          ref={trackRef}\n          onMouseEnter={handleMouseEnter}\n          onMouseLeave={handleMouseLeave}\n        >\n          {logoLists}\n        </div>\n      </div>\n    );\n  }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}