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) => (
0}
@@ -304,7 +361,7 @@ export const LogoLoop = memo(
{logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
)),
- [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 :

}
+
+ )}
+ />
);
}`,
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 :

}
+
+ ) : 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 }\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 }\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 }\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 }\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 :

}
-
- )}
- />
);
}`,
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"
/>