From 58245c867455624212214f89437ad1f35c483fbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:13:36 +0000 Subject: [PATCH 1/7] Initial plan From babd74f68fae25e2ddf6ba9584e026b4c79951cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:24:26 +0000 Subject: [PATCH 2/7] Add hoverSpeed, renderItem props and up/down direction support to all LogoLoop variants Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- src/content/Animations/LogoLoop/LogoLoop.css | 25 ++++ src/content/Animations/LogoLoop/LogoLoop.jsx | 113 +++++++++++---- src/tailwind/Animations/LogoLoop/LogoLoop.jsx | 131 ++++++++++++----- .../Animations/LogoLoop/LogoLoop.css | 25 ++++ .../Animations/LogoLoop/LogoLoop.tsx | 119 +++++++++++---- .../Animations/LogoLoop/LogoLoop.tsx | 137 +++++++++++++----- 6 files changed, 418 insertions(+), 132 deletions(-) diff --git a/src/content/Animations/LogoLoop/LogoLoop.css b/src/content/Animations/LogoLoop/LogoLoop.css index cc60850b..598d585e 100644 --- a/src/content/Animations/LogoLoop/LogoLoop.css +++ b/src/content/Animations/LogoLoop/LogoLoop.css @@ -7,6 +7,11 @@ --logoloop-fadeColorAuto: #ffffff; } +.logoloop--vertical { + overflow-x: visible; + overflow-y: hidden; +} + .logoloop--scale-hover { padding-top: calc(var(--logoloop-logoHeight) * 0.1); padding-bottom: calc(var(--logoloop-logoHeight) * 0.1); @@ -25,11 +30,21 @@ user-select: none; } +.logoloop--vertical .logoloop__track { + flex-direction: column; + height: max-content; + width: auto; +} + .logoloop__list { display: flex; align-items: center; } +.logoloop--vertical .logoloop__list { + flex-direction: column; +} + .logoloop__item { flex: 0 0 auto; margin-right: var(--logoloop-gap); @@ -37,10 +52,20 @@ line-height: 1; } +.logoloop--vertical .logoloop__item { + margin-right: 0; + margin-bottom: var(--logoloop-gap); +} + .logoloop__item:last-child { margin-right: var(--logoloop-gap); } +.logoloop--vertical .logoloop__item:last-child { + margin-right: 0; + margin-bottom: var(--logoloop-gap); +} + .logoloop__node { display: inline-flex; align-items: center; diff --git a/src/content/Animations/LogoLoop/LogoLoop.jsx b/src/content/Animations/LogoLoop/LogoLoop.jsx index 27655ab2..3d0095f7 100644 --- a/src/content/Animations/LogoLoop/LogoLoop.jsx +++ b/src/content/Animations/LogoLoop/LogoLoop.jsx @@ -71,7 +71,7 @@ const useImageLoader = (seqRef, onLoad, dependencies) => { }, dependencies); }; -const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover) => { +const useAnimationLoop = (trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) => { const rafRef = useRef(null); const lastTimestampRef = useRef(null); const offsetRef = useRef(0); @@ -81,9 +81,14 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn const track = trackRef.current; if (!track) return; - if (seqWidth > 0) { - offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; - track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } const animate = timestamp => { @@ -94,18 +99,20 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; - const target = pauseOnHover && isHovered ? 0 : targetVelocity; + const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; - if (seqWidth > 0) { + if (seqSize > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; - nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; offsetRef.current = nextOffset; - const translateX = -offsetRef.current; - track.style.transform = `translate3d(${translateX}px, 0, 0)`; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } rafRef.current = requestAnimationFrame(animate); @@ -120,7 +127,7 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn } lastTimestampRef.current = null; }; - }, [targetVelocity, seqWidth, isHovered, pauseOnHover, trackRef]); + }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical, trackRef]); }; export const LogoLoop = memo( @@ -131,10 +138,12 @@ export const LogoLoop = memo( width = '100%', logoHeight = 28, gap = 32, - pauseOnHover = true, + pauseOnHover, + hoverSpeed, fadeOut = false, fadeOutColor, scaleOnHover = false, + renderItem, ariaLabel = 'Partner logos', className, style @@ -144,32 +153,60 @@ export const LogoLoop = memo( const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); + // Determine the effective hover speed (support backward compatibility) + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + // Default behavior: pause on hover + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); - const directionMultiplier = direction === 'left' ? 1 : -1; + let directionMultiplier; + if (isVertical) { + directionMultiplier = direction === 'up' ? 1 : -1; + } else { + directionMultiplier = direction === 'left' ? 1 : -1; + } const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; - }, [speed, direction]); + }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; - const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; - - if (sequenceWidth > 0) { - setSeqWidth(Math.ceil(sequenceWidth)); - const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; - setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + const containerHeight = containerRef.current?.clientHeight ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + + if (isVertical) { + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else { + if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } } - }, []); + }, [isVertical]); - useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); - useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); - useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); + useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); const cssVariables = useMemo( () => ({ @@ -182,21 +219,37 @@ export const LogoLoop = memo( const rootClassName = useMemo( () => - ['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className] + [ + 'logoloop', + isVertical ? 'logoloop--vertical' : 'logoloop--horizontal', + fadeOut && 'logoloop--fade', + scaleOnHover && 'logoloop--scale-hover', + className + ] .filter(Boolean) .join(' '), - [fadeOut, scaleOnHover, className] + [isVertical, fadeOut, scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { - if (pauseOnHover) setIsHovered(true); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { - if (pauseOnHover) setIsHovered(false); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); const renderLogoItem = useCallback((item, key) => { + // If renderItem prop is provided, use it + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + // Default rendering logic const isNodeItem = 'node' in item; const content = isNodeItem ? ( @@ -239,7 +292,7 @@ export const LogoLoop = memo( {itemContent} ); - }, []); + }, [renderItem]); const logoLists = useMemo( () => diff --git a/src/tailwind/Animations/LogoLoop/LogoLoop.jsx b/src/tailwind/Animations/LogoLoop/LogoLoop.jsx index 4c2dca70..172f1389 100644 --- a/src/tailwind/Animations/LogoLoop/LogoLoop.jsx +++ b/src/tailwind/Animations/LogoLoop/LogoLoop.jsx @@ -72,7 +72,7 @@ const useImageLoader = (seqRef, onLoad, dependencies) => { }, dependencies); }; -const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover) => { +const useAnimationLoop = (trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) => { const rafRef = useRef(null); const lastTimestampRef = useRef(null); const offsetRef = useRef(0); @@ -87,13 +87,18 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (seqWidth > 0) { - offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; - track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } if (prefersReduced) { - track.style.transform = 'translate3d(0, 0, 0)'; + track.style.transform = isVertical ? 'translate3d(0, 0, 0)' : 'translate3d(0, 0, 0)'; return () => { lastTimestampRef.current = null; }; @@ -107,18 +112,20 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; - const target = pauseOnHover && isHovered ? 0 : targetVelocity; + const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; - if (seqWidth > 0) { + if (seqSize > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; - nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; offsetRef.current = nextOffset; - const translateX = -offsetRef.current; - track.style.transform = `translate3d(${translateX}px, 0, 0)`; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } rafRef.current = requestAnimationFrame(animate); @@ -133,7 +140,7 @@ const useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOn } lastTimestampRef.current = null; }; - }, [targetVelocity, seqWidth, isHovered, pauseOnHover, trackRef]); + }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical, trackRef]); }; export const LogoLoop = memo( @@ -144,10 +151,12 @@ export const LogoLoop = memo( width = '100%', logoHeight = 28, gap = 32, - pauseOnHover = true, + pauseOnHover, + hoverSpeed, fadeOut = false, fadeOutColor, scaleOnHover = false, + renderItem, ariaLabel = 'Partner logos', className, style @@ -157,32 +166,60 @@ export const LogoLoop = memo( const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); + // Determine the effective hover speed (support backward compatibility) + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + // Default behavior: pause on hover + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); - const directionMultiplier = direction === 'left' ? 1 : -1; + let directionMultiplier; + if (isVertical) { + directionMultiplier = direction === 'up' ? 1 : -1; + } else { + directionMultiplier = direction === 'left' ? 1 : -1; + } const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; - }, [speed, direction]); + }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; - const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; - - if (sequenceWidth > 0) { - setSeqWidth(Math.ceil(sequenceWidth)); - const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; - setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + const containerHeight = containerRef.current?.clientHeight ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + + if (isVertical) { + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else { + if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } } - }, []); + }, [isVertical]); - useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); - useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); - useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); + useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); const cssVariables = useMemo( () => ({ @@ -196,7 +233,8 @@ export const LogoLoop = memo( const rootClasses = useMemo( () => cx( - 'relative overflow-x-hidden group', + 'relative group', + isVertical ? 'overflow-y-hidden' : 'overflow-x-hidden', '[--logoloop-gap:32px]', '[--logoloop-logoHeight:28px]', '[--logoloop-fadeColorAuto:#ffffff]', @@ -204,19 +242,37 @@ export const LogoLoop = memo( scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]', className ), - [scaleOnHover, className] + [isVertical, scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { - if (pauseOnHover) setIsHovered(true); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { - if (pauseOnHover) setIsHovered(false); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); const renderLogoItem = useCallback( (item, key) => { + // If renderItem prop is provided, use it + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + // Default rendering logic const isNodeItem = 'node' in item; const content = isNodeItem ? ( @@ -278,7 +334,8 @@ export const LogoLoop = memo( return (
  • ); }, - [scaleOnHover] + [isVertical, scaleOnHover, renderItem] ); const logoLists = useMemo( () => Array.from({ length: copyCount }, (_, copyIndex) => ( )), - [copyCount, logos, renderLogoItem] + [copyCount, logos, renderLogoItem, isVertical] ); const containerStyle = useMemo( @@ -348,7 +405,11 @@ export const LogoLoop = memo( )}
    {logoLists} diff --git a/src/ts-default/Animations/LogoLoop/LogoLoop.css b/src/ts-default/Animations/LogoLoop/LogoLoop.css index 87ae3b25..cb7f7664 100644 --- a/src/ts-default/Animations/LogoLoop/LogoLoop.css +++ b/src/ts-default/Animations/LogoLoop/LogoLoop.css @@ -7,6 +7,11 @@ --logoloop-fadeColorAuto: #ffffff; } +.logoloop--vertical { + overflow-x: visible; + overflow-y: hidden; +} + .logoloop--scale-hover { padding-top: calc(var(--logoloop-logoHeight) * 0.1); padding-bottom: calc(var(--logoloop-logoHeight) * 0.1); @@ -25,11 +30,21 @@ user-select: none; } +.logoloop--vertical .logoloop__track { + flex-direction: column; + height: max-content; + width: auto; +} + .logoloop__list { display: flex; align-items: center; } +.logoloop--vertical .logoloop__list { + flex-direction: column; +} + .logoloop__item { flex: 0 0 auto; margin-right: var(--logoloop-gap); @@ -37,10 +52,20 @@ line-height: 1; } +.logoloop--vertical .logoloop__item { + margin-right: 0; + margin-bottom: var(--logoloop-gap); +} + .logoloop__item:last-child { margin-right: var(--logoloop-gap); } +.logoloop--vertical .logoloop__item:last-child { + margin-right: 0; + margin-bottom: var(--logoloop-gap); +} + .logoloop__node { display: inline-flex; align-items: center; diff --git a/src/ts-default/Animations/LogoLoop/LogoLoop.tsx b/src/ts-default/Animations/LogoLoop/LogoLoop.tsx index 3c167b91..cdda4c91 100644 --- a/src/ts-default/Animations/LogoLoop/LogoLoop.tsx +++ b/src/ts-default/Animations/LogoLoop/LogoLoop.tsx @@ -22,14 +22,16 @@ export type LogoItem = export interface LogoLoopProps { logos: LogoItem[]; speed?: number; - direction?: 'left' | 'right'; + direction?: 'left' | 'right' | 'up' | 'down'; width?: number | string; logoHeight?: number; gap?: number; pauseOnHover?: boolean; + hoverSpeed?: number; fadeOut?: boolean; fadeOutColor?: string; scaleOnHover?: boolean; + renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode; ariaLabel?: string; className?: string; style?: React.CSSProperties; @@ -116,8 +118,10 @@ const useAnimationLoop = ( trackRef: React.RefObject, targetVelocity: number, seqWidth: number, + seqHeight: number, isHovered: boolean, - pauseOnHover: boolean + hoverSpeed: number | undefined, + isVertical: boolean ) => { const rafRef = useRef(null); const lastTimestampRef = useRef(null); @@ -128,9 +132,14 @@ const useAnimationLoop = ( const track = trackRef.current; if (!track) return; - if (seqWidth > 0) { - offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; - track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } const animate = (timestamp: number) => { @@ -141,18 +150,20 @@ const useAnimationLoop = ( const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; - const target = pauseOnHover && isHovered ? 0 : targetVelocity; + const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; - if (seqWidth > 0) { + if (seqSize > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; - nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; offsetRef.current = nextOffset; - const translateX = -offsetRef.current; - track.style.transform = `translate3d(${translateX}px, 0, 0)`; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } rafRef.current = requestAnimationFrame(animate); @@ -167,7 +178,7 @@ const useAnimationLoop = ( } lastTimestampRef.current = null; }; - }, [targetVelocity, seqWidth, isHovered, pauseOnHover]); + }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]); }; export const LogoLoop = React.memo( @@ -178,10 +189,12 @@ export const LogoLoop = React.memo( width = '100%', logoHeight = 28, gap = 32, - pauseOnHover = true, + pauseOnHover, + hoverSpeed, fadeOut = false, fadeOutColor, scaleOnHover = false, + renderItem, ariaLabel = 'Partner logos', className, style @@ -191,32 +204,60 @@ export const LogoLoop = React.memo( const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); + // Determine the effective hover speed (support backward compatibility) + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + // Default behavior: pause on hover + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); - const directionMultiplier = direction === 'left' ? 1 : -1; + let directionMultiplier: number; + if (isVertical) { + directionMultiplier = direction === 'up' ? 1 : -1; + } else { + directionMultiplier = direction === 'left' ? 1 : -1; + } const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; - }, [speed, direction]); + }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; - const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; - - if (sequenceWidth > 0) { - setSeqWidth(Math.ceil(sequenceWidth)); - const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; - setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + const containerHeight = containerRef.current?.clientHeight ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + + if (isVertical) { + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else { + if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } } - }, []); + }, [isVertical]); - useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); - useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); - useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); + useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); const cssVariables = useMemo( () => @@ -230,21 +271,37 @@ export const LogoLoop = React.memo( const rootClassName = useMemo( () => - ['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className] + [ + 'logoloop', + isVertical ? 'logoloop--vertical' : 'logoloop--horizontal', + fadeOut && 'logoloop--fade', + scaleOnHover && 'logoloop--scale-hover', + className + ] .filter(Boolean) .join(' '), - [fadeOut, scaleOnHover, className] + [isVertical, fadeOut, scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { - if (pauseOnHover) setIsHovered(true); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { - if (pauseOnHover) setIsHovered(false); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); const renderLogoItem = useCallback((item: LogoItem, key: React.Key) => { + // If renderItem prop is provided, use it + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + // Default rendering logic const isNodeItem = 'node' in item; const content = isNodeItem ? ( @@ -287,7 +344,7 @@ export const LogoLoop = React.memo( {itemContent} ); - }, []); + }, [renderItem]); const logoLists = useMemo( () => diff --git a/src/ts-tailwind/Animations/LogoLoop/LogoLoop.tsx b/src/ts-tailwind/Animations/LogoLoop/LogoLoop.tsx index 18a3d553..893b4f25 100644 --- a/src/ts-tailwind/Animations/LogoLoop/LogoLoop.tsx +++ b/src/ts-tailwind/Animations/LogoLoop/LogoLoop.tsx @@ -21,14 +21,16 @@ export type LogoItem = export interface LogoLoopProps { logos: LogoItem[]; speed?: number; - direction?: 'left' | 'right'; + direction?: 'left' | 'right' | 'up' | 'down'; width?: number | string; logoHeight?: number; gap?: number; pauseOnHover?: boolean; + hoverSpeed?: number; fadeOut?: boolean; fadeOutColor?: string; scaleOnHover?: boolean; + renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode; ariaLabel?: string; className?: string; style?: React.CSSProperties; @@ -117,8 +119,10 @@ const useAnimationLoop = ( trackRef: React.RefObject, targetVelocity: number, seqWidth: number, + seqHeight: number, isHovered: boolean, - pauseOnHover: boolean + hoverSpeed: number | undefined, + isVertical: boolean ) => { const rafRef = useRef(null); const lastTimestampRef = useRef(null); @@ -134,13 +138,18 @@ const useAnimationLoop = ( window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - if (seqWidth > 0) { - offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth; - track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } if (prefersReduced) { - track.style.transform = 'translate3d(0, 0, 0)'; + track.style.transform = isVertical ? 'translate3d(0, 0, 0)' : 'translate3d(0, 0, 0)'; return () => { lastTimestampRef.current = null; }; @@ -154,18 +163,20 @@ const useAnimationLoop = ( const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000; lastTimestampRef.current = timestamp; - const target = pauseOnHover && isHovered ? 0 : targetVelocity; + const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (target - velocityRef.current) * easingFactor; - if (seqWidth > 0) { + if (seqSize > 0) { let nextOffset = offsetRef.current + velocityRef.current * deltaTime; - nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; offsetRef.current = nextOffset; - const translateX = -offsetRef.current; - track.style.transform = `translate3d(${translateX}px, 0, 0)`; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; } rafRef.current = requestAnimationFrame(animate); @@ -180,7 +191,7 @@ const useAnimationLoop = ( } lastTimestampRef.current = null; }; - }, [targetVelocity, seqWidth, isHovered, pauseOnHover]); + }, [targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]); }; export const LogoLoop = React.memo( @@ -191,10 +202,12 @@ export const LogoLoop = React.memo( width = '100%', logoHeight = 28, gap = 32, - pauseOnHover = true, + pauseOnHover, + hoverSpeed, fadeOut = false, fadeOutColor, scaleOnHover = false, + renderItem, ariaLabel = 'Partner logos', className, style @@ -204,32 +217,60 @@ export const LogoLoop = React.memo( const seqRef = useRef(null); const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES); const [isHovered, setIsHovered] = useState(false); + // Determine the effective hover speed (support backward compatibility) + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + // Default behavior: pause on hover + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === 'up' || direction === 'down'; + const targetVelocity = useMemo(() => { const magnitude = Math.abs(speed); - const directionMultiplier = direction === 'left' ? 1 : -1; + let directionMultiplier: number; + if (isVertical) { + directionMultiplier = direction === 'up' ? 1 : -1; + } else { + directionMultiplier = direction === 'left' ? 1 : -1; + } const speedMultiplier = speed < 0 ? -1 : 1; return magnitude * directionMultiplier * speedMultiplier; - }, [speed, direction]); + }, [speed, direction, isVertical]); const updateDimensions = useCallback(() => { const containerWidth = containerRef.current?.clientWidth ?? 0; - const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0; - - if (sequenceWidth > 0) { - setSeqWidth(Math.ceil(sequenceWidth)); - const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; - setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + const containerHeight = containerRef.current?.clientHeight ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + + if (isVertical) { + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else { + if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = Math.ceil(containerWidth / sequenceWidth) + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } } - }, []); + }, [isVertical]); - useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]); + useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]); - useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]); + useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]); - useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover); + useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical); const cssVariables = useMemo( () => @@ -244,7 +285,8 @@ export const LogoLoop = React.memo( const rootClasses = useMemo( () => cx( - 'relative overflow-x-hidden group', + 'relative group', + isVertical ? 'overflow-y-hidden' : 'overflow-x-hidden', '[--logoloop-gap:32px]', '[--logoloop-logoHeight:28px]', '[--logoloop-fadeColorAuto:#ffffff]', @@ -252,19 +294,37 @@ export const LogoLoop = React.memo( scaleOnHover && 'py-[calc(var(--logoloop-logoHeight)*0.1)]', className ), - [scaleOnHover, className] + [isVertical, scaleOnHover, className] ); const handleMouseEnter = useCallback(() => { - if (pauseOnHover) setIsHovered(true); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); const handleMouseLeave = useCallback(() => { - if (pauseOnHover) setIsHovered(false); - }, [pauseOnHover]); + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); const renderLogoItem = useCallback( (item: LogoItem, key: React.Key) => { + // If renderItem prop is provided, use it + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + // Default rendering logic const isNodeItem = 'node' in item; const content = isNodeItem ? ( @@ -328,7 +388,8 @@ export const LogoLoop = React.memo( return (
  • (
  • ); }, - [scaleOnHover] + [isVertical, scaleOnHover, renderItem] ); const logoLists = useMemo( () => Array.from({ length: copyCount }, (_, copyIndex) => (
      0} @@ -354,7 +415,7 @@ export const LogoLoop = React.memo( {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
    )), - [copyCount, logos, renderLogoItem] + [copyCount, logos, renderLogoItem, isVertical] ); const containerStyle = useMemo( @@ -398,7 +459,11 @@ export const LogoLoop = React.memo( )}
    {logoLists} From a65a2efdf867ec28c44ba0613bea135aed04bbfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:25:53 +0000 Subject: [PATCH 3/7] Update LogoLoopDemo and documentation with new props Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- src/constants/code/Animations/logoLoopCode.js | 25 +++++++- src/demo/Animations/LogoLoopDemo.jsx | 64 +++++++++++++++---- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/constants/code/Animations/logoLoopCode.js b/src/constants/code/Animations/logoLoopCode.js index 1e6b6b2a..e11df61d 100644 --- a/src/constants/code/Animations/logoLoopCode.js +++ b/src/constants/code/Animations/logoLoopCode.js @@ -25,18 +25,41 @@ const imageLogos = [ function App() { return (
    + {/* Basic horizontal loop */} + + {/* Vertical loop with deceleration on hover */} + + + {/* Custom rendering with renderItem */} + ( +
    + {'node' in item ? item.node : {item.alt}} +
    + )} + />
    ); }`, diff --git a/src/demo/Animations/LogoLoopDemo.jsx b/src/demo/Animations/LogoLoopDemo.jsx index bc12b8f0..a04cfdda 100644 --- a/src/demo/Animations/LogoLoopDemo.jsx +++ b/src/demo/Animations/LogoLoopDemo.jsx @@ -45,10 +45,11 @@ const LogoLoopDemo = () => { const [speed, setSpeed] = useState(100); const [logoHeight, setLogoHeight] = useState(60); const [gap, setGap] = useState(60); - const [pauseOnHover, setPauseOnHover] = useState(true); + const [hoverSpeed, setHoverSpeed] = useState(0); const [fadeOut, setFadeOut] = useState(true); const [scaleOnHover, setScaleOnHover] = useState(true); const [direction, setDirection] = useState('left'); + const [useCustomRender, setUseCustomRender] = useState(false); const propData = [ { @@ -66,9 +67,9 @@ const LogoLoopDemo = () => { }, { name: 'direction', - type: "'left' | 'right'", + type: "'left' | 'right' | 'up' | 'down'", default: "'left'", - description: 'Direction of the logo animation loop.' + description: 'Direction of the logo animation loop. Supports horizontal (left/right) and vertical (up/down) scrolling.' }, { name: 'width', @@ -92,7 +93,13 @@ const LogoLoopDemo = () => { name: 'pauseOnHover', type: 'boolean', default: 'true', - description: 'Whether to pause the animation when hovering over the component.' + description: 'DEPRECATED: Use hoverSpeed instead. When true, pauses animation on hover (equivalent to hoverSpeed={0}).' + }, + { + name: 'hoverSpeed', + type: 'number | undefined', + default: '0', + description: 'Speed when hovering over the component. Set to 0 to pause, or a lower value for deceleration effect. Overrides pauseOnHover.' }, { name: 'fadeOut', @@ -112,6 +119,12 @@ const LogoLoopDemo = () => { default: 'false', description: 'Whether to scale logos on hover.' }, + { + name: 'renderItem', + type: '(item: LogoItem, key: React.Key) => React.ReactNode', + default: 'undefined', + description: 'Custom render function for each logo item. Allows full control over item rendering for animations, tooltips, etc.' + }, { name: 'ariaLabel', type: 'string', @@ -145,10 +158,20 @@ const LogoLoopDemo = () => { speed={speed} direction={direction} scaleOnHover={scaleOnHover} - pauseOnHover={pauseOnHover} + hoverSpeed={hoverSpeed} fadeOut={fadeOut} fadeOutColor="#060010" ariaLabel="Our tech stack" + renderItem={useCustomRender ? (item, key) => ( +
    + {'node' in item ? item.node : {item.alt}} +
    + ) : undefined} /> @@ -166,6 +189,19 @@ const LogoLoopDemo = () => { }} /> + { + setHoverSpeed(value); + forceRerender(); + }} + /> + { uncheckedLabel="Left" /> - { - setPauseOnHover(checked); - forceRerender(); - }} - /> - { forceRerender(); }} /> + + { + setUseCustomRender(checked); + forceRerender(); + }} + /> From a2d4f34092fbde32b1fe7bfe88d87e39e14197ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:29:21 +0000 Subject: [PATCH 4/7] Fix lint error in LogoLoopDemo Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- public/r/LogoLoop-JS-CSS.json | 4 ++-- public/r/LogoLoop-JS-TW.json | 2 +- public/r/LogoLoop-TS-CSS.json | 4 ++-- public/r/LogoLoop-TS-TW.json | 2 +- src/demo/Animations/LogoLoopDemo.jsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/public/r/LogoLoop-JS-CSS.json b/public/r/LogoLoop-JS-CSS.json index c041e73a..1e070870 100644 --- a/public/r/LogoLoop-JS-CSS.json +++ b/public/r/LogoLoop-JS-CSS.json @@ -7,12 +7,12 @@ "files": [ { "path": "public/default/src/content/Animations/LogoLoop/LogoLoop.jsx", - "content": "import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react';\nimport './LogoLoop.css';\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 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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 // eslint-disable-next-line react-hooks/exhaustive-deps\n }, dependencies);\n};\n\nconst useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover) => {\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 if (seqWidth > 0) {\n offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;\n track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;\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 = pauseOnHover && isHovered ? 0 : targetVelocity;\n\n const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n velocityRef.current += (target - velocityRef.current) * easingFactor;\n\n if (seqWidth > 0) {\n let nextOffset = offsetRef.current + velocityRef.current * deltaTime;\n nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;\n offsetRef.current = nextOffset;\n\n const translateX = -offsetRef.current;\n track.style.transform = `translate3d(${translateX}px, 0, 0)`;\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, isHovered, pauseOnHover, 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 = true,\n fadeOut = false,\n fadeOutColor,\n scaleOnHover = false,\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 [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);\n const [isHovered, setIsHovered] = useState(false);\n\n const targetVelocity = useMemo(() => {\n const magnitude = Math.abs(speed);\n const directionMultiplier = direction === 'left' ? 1 : -1;\n const speedMultiplier = speed < 0 ? -1 : 1;\n return magnitude * directionMultiplier * speedMultiplier;\n }, [speed, direction]);\n\n const updateDimensions = useCallback(() => {\n const containerWidth = containerRef.current?.clientWidth ?? 0;\n const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;\n\n 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 }, []);\n\n useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);\n\n useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);\n\n useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);\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 rootClassName = useMemo(\n () =>\n ['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className]\n .filter(Boolean)\n .join(' '),\n [fadeOut, scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (pauseOnHover) setIsHovered(true);\n }, [pauseOnHover]);\n\n const handleMouseLeave = useCallback(() => {\n if (pauseOnHover) setIsHovered(false);\n }, [pauseOnHover]);\n\n const renderLogoItem = useCallback((item, key) => {\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const itemContent = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n
  • \n {itemContent}\n
  • \n );\n }, []);\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n () => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n
    \n {logoLists}\n
    \n
    \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", + "content": "import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react';\nimport './LogoLoop.css';\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 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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 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 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 // Determine the effective hover speed (support backward compatibility)\n const effectiveHoverSpeed = useMemo(() => {\n if (hoverSpeed !== undefined) return hoverSpeed;\n if (pauseOnHover === true) return 0;\n if (pauseOnHover === false) return undefined;\n // Default behavior: pause on hover\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 containerHeight = containerRef.current?.clientHeight ?? 0;\n const sequenceRect = seqRef.current?.getBoundingClientRect?.();\n const sequenceWidth = sequenceRect?.width ?? 0;\n const sequenceHeight = sequenceRect?.height ?? 0;\n\n if (isVertical) {\n if (sequenceHeight > 0) {\n setSeqHeight(Math.ceil(sequenceHeight));\n const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM;\n setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n }\n } else {\n 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 }\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 rootClassName = useMemo(\n () =>\n [\n 'logoloop',\n isVertical ? 'logoloop--vertical' : 'logoloop--horizontal',\n fadeOut && 'logoloop--fade',\n scaleOnHover && 'logoloop--scale-hover',\n className\n ]\n .filter(Boolean)\n .join(' '),\n [isVertical, fadeOut, scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(true);\n }, [effectiveHoverSpeed]);\n\n const handleMouseLeave = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(false);\n }, [effectiveHoverSpeed]);\n\n const renderLogoItem = useCallback((item, key) => {\n // If renderItem prop is provided, use it\n if (renderItem) {\n return (\n
  • \n {renderItem(item, key)}\n
  • \n );\n }\n\n // Default rendering logic\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const itemContent = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n
  • \n {itemContent}\n
  • \n );\n }, [renderItem]);\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n () => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n
    \n {logoLists}\n
    \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", "type": "registry:component" }, { "path": "public/default/src/content/Animations/LogoLoop/LogoLoop.css", - "content": ".logoloop {\n position: relative;\n overflow-x: hidden;\n\n --logoloop-gap: 32px;\n --logoloop-logoHeight: 28px;\n --logoloop-fadeColorAuto: #ffffff;\n}\n\n.logoloop--scale-hover {\n padding-top: calc(var(--logoloop-logoHeight) * 0.1);\n padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n .logoloop {\n --logoloop-fadeColorAuto: #0b0b0b;\n }\n}\n\n.logoloop__track {\n display: flex;\n width: max-content;\n will-change: transform;\n user-select: none;\n}\n\n.logoloop__list {\n display: flex;\n align-items: center;\n}\n\n.logoloop__item {\n flex: 0 0 auto;\n margin-right: var(--logoloop-gap);\n font-size: var(--logoloop-logoHeight);\n line-height: 1;\n}\n\n.logoloop__item:last-child {\n margin-right: var(--logoloop-gap);\n}\n\n.logoloop__node {\n display: inline-flex;\n align-items: center;\n}\n\n.logoloop__item img {\n height: var(--logoloop-logoHeight);\n width: auto;\n display: block;\n object-fit: contain;\n image-rendering: -webkit-optimize-contrast;\n -webkit-user-drag: none;\n pointer-events: none;\n /* Links handle interaction */\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop--scale-hover .logoloop__item {\n overflow: visible;\n}\n\n.logoloop--scale-hover .logoloop__item:hover img,\n.logoloop--scale-hover .logoloop__item:hover .logoloop__node {\n transform: scale(1.2);\n transform-origin: center center;\n}\n\n.logoloop--scale-hover .logoloop__node {\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop__link {\n display: inline-flex;\n align-items: center;\n text-decoration: none;\n border-radius: 4px;\n transition: opacity 0.2s ease;\n}\n\n.logoloop__link:hover {\n opacity: 0.8;\n}\n\n.logoloop__link:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n}\n\n.logoloop--fade::before,\n.logoloop--fade::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n width: clamp(24px, 8%, 120px);\n pointer-events: none;\n z-index: 1;\n}\n\n.logoloop--fade::before {\n left: 0;\n background: linear-gradient(\n to right,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n.logoloop--fade::after {\n right: 0;\n background: linear-gradient(\n to left,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n@media (prefers-reduced-motion: reduce) {\n .logoloop__track {\n transform: translate3d(0, 0, 0) !important;\n }\n\n .logoloop__item img,\n .logoloop__node {\n transition: none !important;\n }\n}\n", + "content": ".logoloop {\n position: relative;\n overflow-x: hidden;\n\n --logoloop-gap: 32px;\n --logoloop-logoHeight: 28px;\n --logoloop-fadeColorAuto: #ffffff;\n}\n\n.logoloop--vertical {\n overflow-x: visible;\n overflow-y: hidden;\n}\n\n.logoloop--scale-hover {\n padding-top: calc(var(--logoloop-logoHeight) * 0.1);\n padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n .logoloop {\n --logoloop-fadeColorAuto: #0b0b0b;\n }\n}\n\n.logoloop__track {\n display: flex;\n width: max-content;\n will-change: transform;\n user-select: none;\n}\n\n.logoloop--vertical .logoloop__track {\n flex-direction: column;\n height: max-content;\n width: auto;\n}\n\n.logoloop__list {\n display: flex;\n align-items: center;\n}\n\n.logoloop--vertical .logoloop__list {\n flex-direction: column;\n}\n\n.logoloop__item {\n flex: 0 0 auto;\n margin-right: var(--logoloop-gap);\n font-size: var(--logoloop-logoHeight);\n line-height: 1;\n}\n\n.logoloop--vertical .logoloop__item {\n margin-right: 0;\n margin-bottom: var(--logoloop-gap);\n}\n\n.logoloop__item:last-child {\n margin-right: var(--logoloop-gap);\n}\n\n.logoloop--vertical .logoloop__item:last-child {\n margin-right: 0;\n margin-bottom: var(--logoloop-gap);\n}\n\n.logoloop__node {\n display: inline-flex;\n align-items: center;\n}\n\n.logoloop__item img {\n height: var(--logoloop-logoHeight);\n width: auto;\n display: block;\n object-fit: contain;\n image-rendering: -webkit-optimize-contrast;\n -webkit-user-drag: none;\n pointer-events: none;\n /* Links handle interaction */\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop--scale-hover .logoloop__item {\n overflow: visible;\n}\n\n.logoloop--scale-hover .logoloop__item:hover img,\n.logoloop--scale-hover .logoloop__item:hover .logoloop__node {\n transform: scale(1.2);\n transform-origin: center center;\n}\n\n.logoloop--scale-hover .logoloop__node {\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop__link {\n display: inline-flex;\n align-items: center;\n text-decoration: none;\n border-radius: 4px;\n transition: opacity 0.2s ease;\n}\n\n.logoloop__link:hover {\n opacity: 0.8;\n}\n\n.logoloop__link:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n}\n\n.logoloop--fade::before,\n.logoloop--fade::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n width: clamp(24px, 8%, 120px);\n pointer-events: none;\n z-index: 1;\n}\n\n.logoloop--fade::before {\n left: 0;\n background: linear-gradient(\n to right,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n.logoloop--fade::after {\n right: 0;\n background: linear-gradient(\n to left,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n@media (prefers-reduced-motion: reduce) {\n .logoloop__track {\n transform: translate3d(0, 0, 0) !important;\n }\n\n .logoloop__item img,\n .logoloop__node {\n transition: none !important;\n }\n}\n", "type": "registry:item" } ] diff --git a/public/r/LogoLoop-JS-TW.json b/public/r/LogoLoop-JS-TW.json index 36e11e32..a1c03a8e 100644 --- a/public/r/LogoLoop-JS-TW.json +++ b/public/r/LogoLoop-JS-TW.json @@ -7,7 +7,7 @@ "files": [ { "path": "public/tailwind/src/tailwind/Animations/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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 // eslint-disable-next-line react-hooks/exhaustive-deps\n }, dependencies);\n};\n\nconst useAnimationLoop = (trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover) => {\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 if (seqWidth > 0) {\n offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;\n track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;\n }\n\n if (prefersReduced) {\n track.style.transform = '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 = pauseOnHover && isHovered ? 0 : targetVelocity;\n\n const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n velocityRef.current += (target - velocityRef.current) * easingFactor;\n\n if (seqWidth > 0) {\n let nextOffset = offsetRef.current + velocityRef.current * deltaTime;\n nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;\n offsetRef.current = nextOffset;\n\n const translateX = -offsetRef.current;\n track.style.transform = `translate3d(${translateX}px, 0, 0)`;\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, isHovered, pauseOnHover, 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 = true,\n fadeOut = false,\n fadeOutColor,\n scaleOnHover = false,\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 [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);\n const [isHovered, setIsHovered] = useState(false);\n\n const targetVelocity = useMemo(() => {\n const magnitude = Math.abs(speed);\n const directionMultiplier = direction === 'left' ? 1 : -1;\n const speedMultiplier = speed < 0 ? -1 : 1;\n return magnitude * directionMultiplier * speedMultiplier;\n }, [speed, direction]);\n\n const updateDimensions = useCallback(() => {\n const containerWidth = containerRef.current?.clientWidth ?? 0;\n const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;\n\n 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 }, []);\n\n useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);\n\n useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);\n\n useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);\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 overflow-x-hidden group',\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 [scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (pauseOnHover) setIsHovered(true);\n }, [pauseOnHover]);\n\n const handleMouseLeave = useCallback(() => {\n if (pauseOnHover) setIsHovered(false);\n }, [pauseOnHover]);\n\n const renderLogoItem = useCallback(\n (item, key) => {\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const inner = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n \n {inner}\n \n );\n },\n [scaleOnHover]\n );\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n () => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n {fadeOut && (\n <>\n \n \n \n )}\n\n \n {logoLists}\n \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", + "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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 // eslint-disable-next-line react-hooks/exhaustive-deps\n }, 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 // Determine the effective hover speed (support backward compatibility)\n const effectiveHoverSpeed = useMemo(() => {\n if (hoverSpeed !== undefined) return hoverSpeed;\n if (pauseOnHover === true) return 0;\n if (pauseOnHover === false) return undefined;\n // Default behavior: pause on hover\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 containerHeight = containerRef.current?.clientHeight ?? 0;\n const sequenceRect = seqRef.current?.getBoundingClientRect?.();\n const sequenceWidth = sequenceRect?.width ?? 0;\n const sequenceHeight = sequenceRect?.height ?? 0;\n\n if (isVertical) {\n if (sequenceHeight > 0) {\n setSeqHeight(Math.ceil(sequenceHeight));\n const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM;\n setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n }\n } else {\n 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 }\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-y-hidden' : '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\n const handleMouseLeave = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(false);\n }, [effectiveHoverSpeed]);\n\n const renderLogoItem = useCallback(\n (item, key) => {\n // If renderItem prop is provided, use it\n if (renderItem) {\n return (\n \n {renderItem(item, key)}\n \n );\n }\n\n // Default rendering logic\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const inner = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n \n {inner}\n \n );\n },\n [isVertical, scaleOnHover, renderItem]\n );\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem, isVertical]\n );\n\n const containerStyle = useMemo(\n () => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n {fadeOut && (\n <>\n \n \n \n )}\n\n \n {logoLists}\n \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", "type": "registry:component" } ] diff --git a/public/r/LogoLoop-TS-CSS.json b/public/r/LogoLoop-TS-CSS.json index d4364456..278aa749 100644 --- a/public/r/LogoLoop-TS-CSS.json +++ b/public/r/LogoLoop-TS-CSS.json @@ -7,12 +7,12 @@ "files": [ { "path": "public/ts/default/src/ts-default/Animations/LogoLoop/LogoLoop.tsx", - "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport './LogoLoop.css';\n\nexport type LogoItem =\n | {\n node: React.ReactNode;\n href?: string;\n title?: string;\n ariaLabel?: string;\n }\n | {\n src: string;\n alt?: string;\n href?: string;\n title?: string;\n srcSet?: string;\n sizes?: string;\n width?: number;\n height?: number;\n };\n\nexport interface LogoLoopProps {\n logos: LogoItem[];\n speed?: number;\n direction?: 'left' | 'right';\n width?: number | string;\n logoHeight?: number;\n gap?: number;\n pauseOnHover?: boolean;\n fadeOut?: boolean;\n fadeOutColor?: string;\n scaleOnHover?: boolean;\n ariaLabel?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\nconst ANIMATION_CONFIG = {\n SMOOTH_TAU: 0.25,\n MIN_COPIES: 2,\n COPY_HEADROOM: 2\n} as const;\n\nconst toCssLength = (value?: number | string): string | undefined =>\n typeof value === 'number' ? `${value}px` : (value ?? undefined);\n\nconst useResizeObserver = (\n callback: () => void,\n elements: Array>,\n dependencies: React.DependencyList\n) => {\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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n }, dependencies);\n};\n\nconst useImageLoader = (\n seqRef: React.RefObject,\n onLoad: () => void,\n dependencies: React.DependencyList\n) => {\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 as HTMLImageElement;\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 }, dependencies);\n};\n\nconst useAnimationLoop = (\n trackRef: React.RefObject,\n targetVelocity: number,\n seqWidth: number,\n isHovered: boolean,\n pauseOnHover: boolean\n) => {\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 if (seqWidth > 0) {\n offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;\n track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;\n }\n\n const animate = (timestamp: number) => {\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 = pauseOnHover && isHovered ? 0 : targetVelocity;\n\n const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n velocityRef.current += (target - velocityRef.current) * easingFactor;\n\n if (seqWidth > 0) {\n let nextOffset = offsetRef.current + velocityRef.current * deltaTime;\n nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;\n offsetRef.current = nextOffset;\n\n const translateX = -offsetRef.current;\n track.style.transform = `translate3d(${translateX}px, 0, 0)`;\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, isHovered, pauseOnHover]);\n};\n\nexport const LogoLoop = React.memo(\n ({\n logos,\n speed = 120,\n direction = 'left',\n width = '100%',\n logoHeight = 28,\n gap = 32,\n pauseOnHover = true,\n fadeOut = false,\n fadeOutColor,\n scaleOnHover = false,\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 [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);\n const [isHovered, setIsHovered] = useState(false);\n\n const targetVelocity = useMemo(() => {\n const magnitude = Math.abs(speed);\n const directionMultiplier = direction === 'left' ? 1 : -1;\n const speedMultiplier = speed < 0 ? -1 : 1;\n return magnitude * directionMultiplier * speedMultiplier;\n }, [speed, direction]);\n\n const updateDimensions = useCallback(() => {\n const containerWidth = containerRef.current?.clientWidth ?? 0;\n const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;\n\n 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 }, []);\n\n useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);\n\n useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);\n\n useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);\n\n const cssVariables = useMemo(\n () =>\n ({\n '--logoloop-gap': `${gap}px`,\n '--logoloop-logoHeight': `${logoHeight}px`,\n ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })\n }) as React.CSSProperties,\n [gap, logoHeight, fadeOutColor]\n );\n\n const rootClassName = useMemo(\n () =>\n ['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className]\n .filter(Boolean)\n .join(' '),\n [fadeOut, scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (pauseOnHover) setIsHovered(true);\n }, [pauseOnHover]);\n\n const handleMouseLeave = useCallback(() => {\n if (pauseOnHover) setIsHovered(false);\n }, [pauseOnHover]);\n\n const renderLogoItem = useCallback((item: LogoItem, key: React.Key) => {\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const itemContent = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n
  • \n {itemContent}\n
  • \n );\n }, []);\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n (): React.CSSProperties => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n
    \n {logoLists}\n
    \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", + "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport './LogoLoop.css';\n\nexport type LogoItem =\n | {\n node: React.ReactNode;\n href?: string;\n title?: string;\n ariaLabel?: string;\n }\n | {\n src: string;\n alt?: string;\n href?: string;\n title?: string;\n srcSet?: string;\n sizes?: string;\n width?: number;\n height?: number;\n };\n\nexport interface LogoLoopProps {\n logos: LogoItem[];\n speed?: number;\n direction?: 'left' | 'right' | 'up' | 'down';\n width?: number | string;\n logoHeight?: number;\n gap?: number;\n pauseOnHover?: boolean;\n hoverSpeed?: number;\n fadeOut?: boolean;\n fadeOutColor?: string;\n scaleOnHover?: boolean;\n renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode;\n ariaLabel?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\nconst ANIMATION_CONFIG = {\n SMOOTH_TAU: 0.25,\n MIN_COPIES: 2,\n COPY_HEADROOM: 2\n} as const;\n\nconst toCssLength = (value?: number | string): string | undefined =>\n typeof value === 'number' ? `${value}px` : (value ?? undefined);\n\nconst useResizeObserver = (\n callback: () => void,\n elements: Array>,\n dependencies: React.DependencyList\n) => {\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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n }, dependencies);\n};\n\nconst useImageLoader = (\n seqRef: React.RefObject,\n onLoad: () => void,\n dependencies: React.DependencyList\n) => {\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 as HTMLImageElement;\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 }, dependencies);\n};\n\nconst useAnimationLoop = (\n trackRef: React.RefObject,\n targetVelocity: number,\n seqWidth: number,\n seqHeight: number,\n isHovered: boolean,\n hoverSpeed: number | undefined,\n isVertical: boolean\n) => {\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 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 const animate = (timestamp: number) => {\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]);\n};\n\nexport const LogoLoop = React.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 // Determine the effective hover speed (support backward compatibility)\n const effectiveHoverSpeed = useMemo(() => {\n if (hoverSpeed !== undefined) return hoverSpeed;\n if (pauseOnHover === true) return 0;\n if (pauseOnHover === false) return undefined;\n // Default behavior: pause on hover\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: number;\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 containerHeight = containerRef.current?.clientHeight ?? 0;\n const sequenceRect = seqRef.current?.getBoundingClientRect?.();\n const sequenceWidth = sequenceRect?.width ?? 0;\n const sequenceHeight = sequenceRect?.height ?? 0;\n\n if (isVertical) {\n if (sequenceHeight > 0) {\n setSeqHeight(Math.ceil(sequenceHeight));\n const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM;\n setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n }\n } else {\n 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 }\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 ({\n '--logoloop-gap': `${gap}px`,\n '--logoloop-logoHeight': `${logoHeight}px`,\n ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })\n }) as React.CSSProperties,\n [gap, logoHeight, fadeOutColor]\n );\n\n const rootClassName = useMemo(\n () =>\n [\n 'logoloop',\n isVertical ? 'logoloop--vertical' : 'logoloop--horizontal',\n fadeOut && 'logoloop--fade',\n scaleOnHover && 'logoloop--scale-hover',\n className\n ]\n .filter(Boolean)\n .join(' '),\n [isVertical, fadeOut, scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(true);\n }, [effectiveHoverSpeed]);\n\n const handleMouseLeave = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(false);\n }, [effectiveHoverSpeed]);\n\n const renderLogoItem = useCallback((item: LogoItem, key: React.Key) => {\n // If renderItem prop is provided, use it\n if (renderItem) {\n return (\n
  • \n {renderItem(item, key)}\n
  • \n );\n }\n\n // Default rendering logic\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {item.node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);\n\n const itemContent = item.href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n
  • \n {itemContent}\n
  • \n );\n }, [renderItem]);\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n (): React.CSSProperties => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n
    \n {logoLists}\n
    \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", "type": "registry:component" }, { "path": "public/ts/default/src/ts-default/Animations/LogoLoop/LogoLoop.css", - "content": ".logoloop {\n position: relative;\n overflow-x: hidden;\n\n --logoloop-gap: 32px;\n --logoloop-logoHeight: 28px;\n --logoloop-fadeColorAuto: #ffffff;\n}\n\n.logoloop--scale-hover {\n padding-top: calc(var(--logoloop-logoHeight) * 0.1);\n padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n .logoloop {\n --logoloop-fadeColorAuto: #0b0b0b;\n }\n}\n\n.logoloop__track {\n display: flex;\n width: max-content;\n will-change: transform;\n user-select: none;\n}\n\n.logoloop__list {\n display: flex;\n align-items: center;\n}\n\n.logoloop__item {\n flex: 0 0 auto;\n margin-right: var(--logoloop-gap);\n font-size: var(--logoloop-logoHeight);\n line-height: 1;\n}\n\n.logoloop__item:last-child {\n margin-right: var(--logoloop-gap);\n}\n\n.logoloop__node {\n display: inline-flex;\n align-items: center;\n}\n\n.logoloop__item img {\n height: var(--logoloop-logoHeight);\n width: auto;\n display: block;\n object-fit: contain;\n image-rendering: -webkit-optimize-contrast;\n -webkit-user-drag: none;\n pointer-events: none; /* Links handle interaction */\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop--scale-hover .logoloop__item {\n overflow: visible;\n}\n\n.logoloop--scale-hover .logoloop__item:hover img,\n.logoloop--scale-hover .logoloop__item:hover .logoloop__node {\n transform: scale(1.2);\n transform-origin: center center;\n}\n\n.logoloop--scale-hover .logoloop__node {\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop__link {\n display: inline-flex;\n align-items: center;\n text-decoration: none;\n border-radius: 4px;\n transition: opacity 0.2s ease;\n}\n\n.logoloop__link:hover {\n opacity: 0.8;\n}\n\n.logoloop__link:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n}\n\n.logoloop--fade::before,\n.logoloop--fade::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n width: clamp(24px, 8%, 120px);\n pointer-events: none;\n z-index: 1;\n}\n\n.logoloop--fade::before {\n left: 0;\n background: linear-gradient(\n to right,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n.logoloop--fade::after {\n right: 0;\n background: linear-gradient(\n to left,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n@media (prefers-reduced-motion: reduce) {\n .logoloop__track {\n transform: translate3d(0, 0, 0) !important;\n }\n\n .logoloop__item img,\n .logoloop__node {\n transition: none !important;\n }\n}\n", + "content": ".logoloop {\n position: relative;\n overflow-x: hidden;\n\n --logoloop-gap: 32px;\n --logoloop-logoHeight: 28px;\n --logoloop-fadeColorAuto: #ffffff;\n}\n\n.logoloop--vertical {\n overflow-x: visible;\n overflow-y: hidden;\n}\n\n.logoloop--scale-hover {\n padding-top: calc(var(--logoloop-logoHeight) * 0.1);\n padding-bottom: calc(var(--logoloop-logoHeight) * 0.1);\n}\n\n@media (prefers-color-scheme: dark) {\n .logoloop {\n --logoloop-fadeColorAuto: #0b0b0b;\n }\n}\n\n.logoloop__track {\n display: flex;\n width: max-content;\n will-change: transform;\n user-select: none;\n}\n\n.logoloop--vertical .logoloop__track {\n flex-direction: column;\n height: max-content;\n width: auto;\n}\n\n.logoloop__list {\n display: flex;\n align-items: center;\n}\n\n.logoloop--vertical .logoloop__list {\n flex-direction: column;\n}\n\n.logoloop__item {\n flex: 0 0 auto;\n margin-right: var(--logoloop-gap);\n font-size: var(--logoloop-logoHeight);\n line-height: 1;\n}\n\n.logoloop--vertical .logoloop__item {\n margin-right: 0;\n margin-bottom: var(--logoloop-gap);\n}\n\n.logoloop__item:last-child {\n margin-right: var(--logoloop-gap);\n}\n\n.logoloop--vertical .logoloop__item:last-child {\n margin-right: 0;\n margin-bottom: var(--logoloop-gap);\n}\n\n.logoloop__node {\n display: inline-flex;\n align-items: center;\n}\n\n.logoloop__item img {\n height: var(--logoloop-logoHeight);\n width: auto;\n display: block;\n object-fit: contain;\n image-rendering: -webkit-optimize-contrast;\n -webkit-user-drag: none;\n pointer-events: none; /* Links handle interaction */\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop--scale-hover .logoloop__item {\n overflow: visible;\n}\n\n.logoloop--scale-hover .logoloop__item:hover img,\n.logoloop--scale-hover .logoloop__item:hover .logoloop__node {\n transform: scale(1.2);\n transform-origin: center center;\n}\n\n.logoloop--scale-hover .logoloop__node {\n transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);\n}\n\n.logoloop__link {\n display: inline-flex;\n align-items: center;\n text-decoration: none;\n border-radius: 4px;\n transition: opacity 0.2s ease;\n}\n\n.logoloop__link:hover {\n opacity: 0.8;\n}\n\n.logoloop__link:focus-visible {\n outline: 2px solid currentColor;\n outline-offset: 2px;\n}\n\n.logoloop--fade::before,\n.logoloop--fade::after {\n content: '';\n position: absolute;\n top: 0;\n bottom: 0;\n width: clamp(24px, 8%, 120px);\n pointer-events: none;\n z-index: 1;\n}\n\n.logoloop--fade::before {\n left: 0;\n background: linear-gradient(\n to right,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n.logoloop--fade::after {\n right: 0;\n background: linear-gradient(\n to left,\n var(--logoloop-fadeColor, var(--logoloop-fadeColorAuto)) 0%,\n rgba(0, 0, 0, 0) 100%\n );\n}\n\n@media (prefers-reduced-motion: reduce) {\n .logoloop__track {\n transform: translate3d(0, 0, 0) !important;\n }\n\n .logoloop__item img,\n .logoloop__node {\n transition: none !important;\n }\n}\n", "type": "registry:item" } ] diff --git a/public/r/LogoLoop-TS-TW.json b/public/r/LogoLoop-TS-TW.json index cf4b601d..7c134349 100644 --- a/public/r/LogoLoop-TS-TW.json +++ b/public/r/LogoLoop-TS-TW.json @@ -7,7 +7,7 @@ "files": [ { "path": "public/ts/tailwind/src/ts-tailwind/Animations/LogoLoop/LogoLoop.tsx", - "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nexport type LogoItem =\n | {\n node: React.ReactNode;\n href?: string;\n title?: string;\n ariaLabel?: string;\n }\n | {\n src: string;\n alt?: string;\n href?: string;\n title?: string;\n srcSet?: string;\n sizes?: string;\n width?: number;\n height?: number;\n };\n\nexport interface LogoLoopProps {\n logos: LogoItem[];\n speed?: number;\n direction?: 'left' | 'right';\n width?: number | string;\n logoHeight?: number;\n gap?: number;\n pauseOnHover?: boolean;\n fadeOut?: boolean;\n fadeOutColor?: string;\n scaleOnHover?: boolean;\n ariaLabel?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\nconst ANIMATION_CONFIG = {\n SMOOTH_TAU: 0.25,\n MIN_COPIES: 2,\n COPY_HEADROOM: 2\n} as const;\n\nconst toCssLength = (value?: number | string): string | undefined =>\n typeof value === 'number' ? `${value}px` : (value ?? undefined);\n\nconst cx = (...parts: Array) => parts.filter(Boolean).join(' ');\n\nconst useResizeObserver = (\n callback: () => void,\n elements: Array>,\n dependencies: React.DependencyList\n) => {\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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n }, dependencies);\n};\n\nconst useImageLoader = (\n seqRef: React.RefObject,\n onLoad: () => void,\n dependencies: React.DependencyList\n) => {\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 as HTMLImageElement;\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 }, dependencies);\n};\n\nconst useAnimationLoop = (\n trackRef: React.RefObject,\n targetVelocity: number,\n seqWidth: number,\n isHovered: boolean,\n pauseOnHover: boolean\n) => {\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 if (seqWidth > 0) {\n offsetRef.current = ((offsetRef.current % seqWidth) + seqWidth) % seqWidth;\n track.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`;\n }\n\n if (prefersReduced) {\n track.style.transform = 'translate3d(0, 0, 0)';\n return () => {\n lastTimestampRef.current = null;\n };\n }\n\n const animate = (timestamp: number) => {\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 = pauseOnHover && isHovered ? 0 : targetVelocity;\n\n const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);\n velocityRef.current += (target - velocityRef.current) * easingFactor;\n\n if (seqWidth > 0) {\n let nextOffset = offsetRef.current + velocityRef.current * deltaTime;\n nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;\n offsetRef.current = nextOffset;\n\n const translateX = -offsetRef.current;\n track.style.transform = `translate3d(${translateX}px, 0, 0)`;\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, isHovered, pauseOnHover]);\n};\n\nexport const LogoLoop = React.memo(\n ({\n logos,\n speed = 120,\n direction = 'left',\n width = '100%',\n logoHeight = 28,\n gap = 32,\n pauseOnHover = true,\n fadeOut = false,\n fadeOutColor,\n scaleOnHover = false,\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 [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);\n const [isHovered, setIsHovered] = useState(false);\n\n const targetVelocity = useMemo(() => {\n const magnitude = Math.abs(speed);\n const directionMultiplier = direction === 'left' ? 1 : -1;\n const speedMultiplier = speed < 0 ? -1 : 1;\n return magnitude * directionMultiplier * speedMultiplier;\n }, [speed, direction]);\n\n const updateDimensions = useCallback(() => {\n const containerWidth = containerRef.current?.clientWidth ?? 0;\n const sequenceWidth = seqRef.current?.getBoundingClientRect?.()?.width ?? 0;\n\n 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 }, []);\n\n useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);\n\n useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);\n\n useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);\n\n const cssVariables = useMemo(\n () =>\n ({\n '--logoloop-gap': `${gap}px`,\n '--logoloop-logoHeight': `${logoHeight}px`,\n ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })\n }) as React.CSSProperties,\n [gap, logoHeight, fadeOutColor]\n );\n\n const rootClasses = useMemo(\n () =>\n cx(\n 'relative overflow-x-hidden group',\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 [scaleOnHover, className]\n );\n\n const handleMouseEnter = useCallback(() => {\n if (pauseOnHover) setIsHovered(true);\n }, [pauseOnHover]);\n\n const handleMouseLeave = useCallback(() => {\n if (pauseOnHover) setIsHovered(false);\n }, [pauseOnHover]);\n\n const renderLogoItem = useCallback(\n (item: LogoItem, key: React.Key) => {\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {(item as any).node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem\n ? ((item as any).ariaLabel ?? (item as any).title)\n : ((item as any).alt ?? (item as any).title);\n\n const inner = (item as any).href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n \n {inner}\n \n );\n },\n [scaleOnHover]\n );\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem]\n );\n\n const containerStyle = useMemo(\n (): React.CSSProperties => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n {fadeOut && (\n <>\n \n \n \n )}\n\n \n {logoLists}\n \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", + "content": "import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nexport type LogoItem =\n | {\n node: React.ReactNode;\n href?: string;\n title?: string;\n ariaLabel?: string;\n }\n | {\n src: string;\n alt?: string;\n href?: string;\n title?: string;\n srcSet?: string;\n sizes?: string;\n width?: number;\n height?: number;\n };\n\nexport interface LogoLoopProps {\n logos: LogoItem[];\n speed?: number;\n direction?: 'left' | 'right' | 'up' | 'down';\n width?: number | string;\n logoHeight?: number;\n gap?: number;\n pauseOnHover?: boolean;\n hoverSpeed?: number;\n fadeOut?: boolean;\n fadeOutColor?: string;\n scaleOnHover?: boolean;\n renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode;\n ariaLabel?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\nconst ANIMATION_CONFIG = {\n SMOOTH_TAU: 0.25,\n MIN_COPIES: 2,\n COPY_HEADROOM: 2\n} as const;\n\nconst toCssLength = (value?: number | string): string | undefined =>\n typeof value === 'number' ? `${value}px` : (value ?? undefined);\n\nconst cx = (...parts: Array) => parts.filter(Boolean).join(' ');\n\nconst useResizeObserver = (\n callback: () => void,\n elements: Array>,\n dependencies: React.DependencyList\n) => {\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\n return () => {\n observers.forEach(observer => observer?.disconnect());\n };\n }, dependencies);\n};\n\nconst useImageLoader = (\n seqRef: React.RefObject,\n onLoad: () => void,\n dependencies: React.DependencyList\n) => {\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 as HTMLImageElement;\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 }, dependencies);\n};\n\nconst useAnimationLoop = (\n trackRef: React.RefObject,\n targetVelocity: number,\n seqWidth: number,\n seqHeight: number,\n isHovered: boolean,\n hoverSpeed: number | undefined,\n isVertical: boolean\n) => {\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: number) => {\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]);\n};\n\nexport const LogoLoop = React.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 // Determine the effective hover speed (support backward compatibility)\n const effectiveHoverSpeed = useMemo(() => {\n if (hoverSpeed !== undefined) return hoverSpeed;\n if (pauseOnHover === true) return 0;\n if (pauseOnHover === false) return undefined;\n // Default behavior: pause on hover\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: number;\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 containerHeight = containerRef.current?.clientHeight ?? 0;\n const sequenceRect = seqRef.current?.getBoundingClientRect?.();\n const sequenceWidth = sequenceRect?.width ?? 0;\n const sequenceHeight = sequenceRect?.height ?? 0;\n\n if (isVertical) {\n if (sequenceHeight > 0) {\n setSeqHeight(Math.ceil(sequenceHeight));\n const copiesNeeded = Math.ceil(containerHeight / sequenceHeight) + ANIMATION_CONFIG.COPY_HEADROOM;\n setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));\n }\n } else {\n 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 }\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 ({\n '--logoloop-gap': `${gap}px`,\n '--logoloop-logoHeight': `${logoHeight}px`,\n ...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })\n }) as React.CSSProperties,\n [gap, logoHeight, fadeOutColor]\n );\n\n const rootClasses = useMemo(\n () =>\n cx(\n 'relative group',\n isVertical ? 'overflow-y-hidden' : '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\n const handleMouseLeave = useCallback(() => {\n if (effectiveHoverSpeed !== undefined) setIsHovered(false);\n }, [effectiveHoverSpeed]);\n\n const renderLogoItem = useCallback(\n (item: LogoItem, key: React.Key) => {\n // If renderItem prop is provided, use it\n if (renderItem) {\n return (\n \n {renderItem(item, key)}\n \n );\n }\n\n // Default rendering logic\n const isNodeItem = 'node' in item;\n\n const content = isNodeItem ? (\n \n {(item as any).node}\n \n ) : (\n \n );\n\n const itemAriaLabel = isNodeItem\n ? ((item as any).ariaLabel ?? (item as any).title)\n : ((item as any).alt ?? (item as any).title);\n\n const inner = (item as any).href ? (\n \n {content}\n \n ) : (\n content\n );\n\n return (\n \n {inner}\n \n );\n },\n [isVertical, scaleOnHover, renderItem]\n );\n\n const logoLists = useMemo(\n () =>\n Array.from({ length: copyCount }, (_, copyIndex) => (\n 0}\n ref={copyIndex === 0 ? seqRef : undefined}\n >\n {logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}\n \n )),\n [copyCount, logos, renderLogoItem, isVertical]\n );\n\n const containerStyle = useMemo(\n (): React.CSSProperties => ({\n width: toCssLength(width) ?? '100%',\n ...cssVariables,\n ...style\n }),\n [width, cssVariables, style]\n );\n\n return (\n \n {fadeOut && (\n <>\n \n \n \n )}\n\n \n {logoLists}\n \n \n );\n }\n);\n\nLogoLoop.displayName = 'LogoLoop';\n\nexport default LogoLoop;\n", "type": "registry:component" } ] diff --git a/src/demo/Animations/LogoLoopDemo.jsx b/src/demo/Animations/LogoLoopDemo.jsx index a04cfdda..feacae6a 100644 --- a/src/demo/Animations/LogoLoopDemo.jsx +++ b/src/demo/Animations/LogoLoopDemo.jsx @@ -162,7 +162,7 @@ const LogoLoopDemo = () => { fadeOut={fadeOut} fadeOutColor="#060010" ariaLabel="Our tech stack" - renderItem={useCustomRender ? (item, key) => ( + renderItem={useCustomRender ? (item) => (
    Date: Sun, 9 Nov 2025 18:47:58 +0000 Subject: [PATCH 5/7] Remove renderItem example from usage code Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- src/constants/code/Animations/logoLoopCode.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/constants/code/Animations/logoLoopCode.js b/src/constants/code/Animations/logoLoopCode.js index e11df61d..1447bb03 100644 --- a/src/constants/code/Animations/logoLoopCode.js +++ b/src/constants/code/Animations/logoLoopCode.js @@ -49,17 +49,6 @@ function App() { hoverSpeed={20} fadeOut /> - - {/* Custom rendering with renderItem */} - ( -
    - {'node' in item ? item.node : {item.alt}} -
    - )} - />
    ); }`, From 9e8db5993aaf70e27990b3651fe27f607dbfccb5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:51:41 +0000 Subject: [PATCH 6/7] Remove deprecated pauseOnHover from prop table Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- src/demo/Animations/LogoLoopDemo.jsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/demo/Animations/LogoLoopDemo.jsx b/src/demo/Animations/LogoLoopDemo.jsx index feacae6a..b412b7f9 100644 --- a/src/demo/Animations/LogoLoopDemo.jsx +++ b/src/demo/Animations/LogoLoopDemo.jsx @@ -89,17 +89,11 @@ const LogoLoopDemo = () => { default: '32', description: 'Gap between logos in pixels.' }, - { - name: 'pauseOnHover', - type: 'boolean', - default: 'true', - description: 'DEPRECATED: Use hoverSpeed instead. When true, pauses animation on hover (equivalent to hoverSpeed={0}).' - }, { name: 'hoverSpeed', type: 'number | undefined', default: '0', - description: 'Speed when hovering over the component. Set to 0 to pause, or a lower value for deceleration effect. Overrides pauseOnHover.' + description: 'Speed when hovering over the component. Set to 0 to pause, or a lower value for deceleration effect.' }, { name: 'fadeOut', From 095f68c186601e9c0e15082963a679871ebe2c1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 18:59:02 +0000 Subject: [PATCH 7/7] Replace direction PreviewSwitch with PreviewSelect for all 4 direction options Co-authored-by: DavidHDev <48634587+DavidHDev@users.noreply.github.com> --- src/demo/Animations/LogoLoopDemo.jsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/demo/Animations/LogoLoopDemo.jsx b/src/demo/Animations/LogoLoopDemo.jsx index b412b7f9..d01ead03 100644 --- a/src/demo/Animations/LogoLoopDemo.jsx +++ b/src/demo/Animations/LogoLoopDemo.jsx @@ -8,6 +8,7 @@ import CodeExample from '../../components/code/CodeExample'; import PropTable from '../../components/common/Preview/PropTable'; import PreviewSlider from '../../components/common/Preview/PreviewSlider'; import PreviewSwitch from '../../components/common/Preview/PreviewSwitch'; +import PreviewSelect from '../../components/common/Preview/PreviewSelect'; import useForceRerender from '../../hooks/useForceRerender'; import { logoLoop } from '../../constants/code/Animations/logoLoopCode'; @@ -51,6 +52,13 @@ const LogoLoopDemo = () => { const [direction, setDirection] = useState('left'); const [useCustomRender, setUseCustomRender] = useState(false); + const directionOptions = [ + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + { value: 'up', label: 'Up' }, + { value: 'down', label: 'Down' } + ]; + const propData = [ { name: 'logos', @@ -222,15 +230,14 @@ const LogoLoopDemo = () => { }} /> - { - setDirection(checked ? 'right' : 'left'); + options={directionOptions} + value={direction} + onChange={value => { + setDirection(value); forceRerender(); }} - checkedLabel="Right" - uncheckedLabel="Left" />