import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import './LogoLoop.css';
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
};
const toCssLength = (value) =>
typeof value === 'number' ? `${value}px` : value ?? undefined;
const cx = (...parts) => parts.filter(Boolean).join(' ');
function useResizeObserver(callback, elements, deps) {
useEffect(() => {
if (!window.ResizeObserver) {
const handler = () => callback();
window.addEventListener('resize', handler);
callback();
return () => window.removeEventListener('resize', handler);
}
const observers = elements.map((ref) => {
if (!ref.current) return null;
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
return observer;
});
callback();
return () => {
observers.forEach((o) => o?.disconnect());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useImageLoader(seqRef, onLoad, deps) {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remaining = images.length;
const handleLoad = () => {
remaining -= 1;
if (remaining === 0) onLoad();
};
images.forEach((img) => {
if (img.complete) {
handleLoad();
} else {
img.addEventListener('load', handleLoad, { once: true });
img.addEventListener('error', handleLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener('load', handleLoad);
img.removeEventListener('error', handleLoad);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
const rafRef = useRef(null);
const lastTsRef = useRef(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const seqSize = isVertical ? seqHeight : seqWidth;
if (seqSize > 0) {
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
if (prefersReduced) {
track.style.transform = 'translate3d(0, 0, 0)';
return () => {
lastTsRef.current = null;
};
}
const animate = (ts) => {
if (lastTsRef.current === null) lastTsRef.current = ts;
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easing;
if (seqSize > 0) {
let next = offsetRef.current + velocityRef.current * dt;
next = ((next % seqSize) + seqSize) % seqSize;
offsetRef.current = next;
track.style.transform = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
lastTsRef.current = null;
};
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
}
export default function LogoLoop({
logos,
speed = 60,
direction = 'left',
width = '100%',
logoHeight = 36,
gap = 32,
pauseOnHover = true,
hoverSpeed,
fadeOut = true,
fadeOutColor,
scaleOnHover = true,
ariaLabel = 'Skill logos',
className,
style,
}) {
const containerRef = useRef(null);
const trackRef = useRef(null);
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);
const effectiveHoverSpeed = useMemo(() => {
if (hoverSpeed !== undefined) return hoverSpeed;
if (pauseOnHover === true) return 0;
if (pauseOnHover === false) return undefined;
return 0;
}, [hoverSpeed, pauseOnHover]);
const isVertical = direction === 'up' || direction === 'down';
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const dirMul = isVertical
? direction === 'up'
? 1
: -1
: direction === 'left'
? 1
: -1;
const speedMul = speed < 0 ? -1 : 1;
return magnitude * dirMul * speedMul;
}, [speed, direction, isVertical]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const seqRect = seqRef.current?.getBoundingClientRect?.();
const sw = seqRect?.width ?? 0;
const sh = seqRect?.height ?? 0;
if (isVertical) {
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
if (containerRef.current && parentH > 0) {
const h = Math.ceil(parentH);
if (containerRef.current.style.height !== `${h}px`)
containerRef.current.style.height = `${h}px`;
}
if (sh > 0) {
setSeqHeight(Math.ceil(sh));
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
} else if (sw > 0) {
setSeqWidth(Math.ceil(sw));
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
}
}, [isVertical]);
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight, isVertical]);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight, isVertical]);
useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, effectiveHoverSpeed, isVertical);
const cssVars = useMemo(() => ({
'--logoloop-gap': `${gap}px`,
'--logoloop-logoHeight': `${logoHeight}px`,
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
}), [gap, logoHeight, fadeOutColor]);
const containerStyle = useMemo(() => ({
width: isVertical
? toCssLength(width) === '100%'
? undefined
: toCssLength(width)
: toCssLength(width) ?? '100%',
...cssVars,
...style,
}), [width, cssVars, style, isVertical]);
const handleEnter = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
}, [effectiveHoverSpeed]);
const handleLeave = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
}, [effectiveHoverSpeed]);
const renderItem = (item, key) => {
const isNode = 'node' in item;
const inner = isNode ? (
{item.node}
) : (
);
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
const wrapper = item.href ? (
{inner}
) : (
inner
);
return (