Files
Webklar.com/components/LogoLoop.tsx
Basilosaurusrex f027651f9b main repo
2025-11-24 18:09:40 +01:00

326 lines
8.8 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useRef, useState, memo, ReactNode } from 'react';
import './LogoLoop.css';
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2
};
const toCssLength = (value: number | string | undefined): string | undefined =>
(typeof value === 'number' ? `${value}px` : (value ?? undefined));
interface LogoItem {
node?: ReactNode;
src?: string;
srcSet?: string;
sizes?: string;
width?: number;
height?: number;
alt?: string;
title?: string;
href?: string;
ariaLabel?: string;
}
interface LogoLoopProps {
logos: LogoItem[];
speed?: number;
direction?: 'left' | 'right';
width?: number | string;
logoHeight?: number;
gap?: number;
pauseOnHover?: boolean;
fadeOut?: boolean;
fadeOutColor?: string;
scaleOnHover?: boolean;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
}
const useResizeObserver = (
callback: () => void,
elements: React.RefObject<HTMLElement>[],
dependencies: unknown[]
) => {
useEffect(() => {
if (!window.ResizeObserver) {
const handleResize = () => callback();
window.addEventListener('resize', handleResize);
callback();
return () => window.removeEventListener('resize', handleResize);
}
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(observer => observer?.disconnect());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
};
const useImageLoader = (
seqRef: React.RefObject<HTMLElement>,
onLoad: () => void,
dependencies: unknown[]
) => {
useEffect(() => {
const images = seqRef.current?.querySelectorAll('img') ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) {
onLoad();
}
};
images.forEach(img => {
const htmlImg = img as HTMLImageElement;
if (htmlImg.complete) {
handleImageLoad();
} else {
htmlImg.addEventListener('load', handleImageLoad, { once: true });
htmlImg.addEventListener('error', handleImageLoad, { once: true });
}
});
return () => {
images.forEach(img => {
img.removeEventListener('load', handleImageLoad);
img.removeEventListener('error', handleImageLoad);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
};
const useAnimationLoop = (
trackRef: React.RefObject<HTMLElement>,
targetVelocity: number,
seqWidth: number,
isHovered: boolean,
pauseOnHover: boolean
) => {
const rafRef = useRef<number | null>(null);
const lastTimestampRef = useRef<number | null>(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
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 animate = (timestamp: number) => {
if (lastTimestampRef.current === null) {
lastTimestampRef.current = timestamp;
}
const deltaTime = Math.max(0, timestamp - lastTimestampRef.current) / 1000;
lastTimestampRef.current = timestamp;
const target = pauseOnHover && isHovered ? 0 : targetVelocity;
const easingFactor = 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
if (seqWidth > 0) {
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqWidth) + seqWidth) % seqWidth;
offsetRef.current = nextOffset;
const translateX = -offsetRef.current;
track.style.transform = `translate3d(${translateX}px, 0, 0)`;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTimestampRef.current = null;
};
}, [targetVelocity, seqWidth, isHovered, pauseOnHover, trackRef]);
};
export const LogoLoop = memo<LogoLoopProps>(({
logos,
speed = 120,
direction = 'left',
width = '100%',
logoHeight = 28,
gap = 32,
pauseOnHover = true,
fadeOut = false,
fadeOutColor,
scaleOnHover = false,
ariaLabel = 'Partner logos',
className,
style
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const seqRef = useRef<HTMLUListElement>(null);
const [seqWidth, setSeqWidth] = useState(0);
const [copyCount, setCopyCount] = useState(ANIMATION_CONFIG.MIN_COPIES);
const [isHovered, setIsHovered] = useState(false);
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
const directionMultiplier = direction === 'left' ? 1 : -1;
const speedMultiplier = speed < 0 ? -1 : 1;
return magnitude * directionMultiplier * speedMultiplier;
}, [speed, direction]);
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));
}
}, []);
useResizeObserver(updateDimensions, [containerRef, seqRef], [logos, gap, logoHeight]);
useImageLoader(seqRef, updateDimensions, [logos, gap, logoHeight]);
useAnimationLoop(trackRef, targetVelocity, seqWidth, isHovered, pauseOnHover);
const cssVariables = useMemo(
() => ({
'--logoloop-gap': `${gap}px`,
'--logoloop-logoHeight': `${logoHeight}px`,
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor })
}),
[gap, logoHeight, fadeOutColor]
);
const rootClassName = useMemo(
() =>
['logoloop', fadeOut && 'logoloop--fade', scaleOnHover && 'logoloop--scale-hover', className]
.filter(Boolean)
.join(' '),
[fadeOut, scaleOnHover, className]
);
const handleMouseEnter = useCallback(() => {
if (pauseOnHover) setIsHovered(true);
}, [pauseOnHover]);
const handleMouseLeave = useCallback(() => {
if (pauseOnHover) setIsHovered(false);
}, [pauseOnHover]);
const renderLogoItem = useCallback((item: LogoItem, key: string) => {
const isNodeItem = 'node' in item;
const content = isNodeItem ? (
<span className="logoloop__node" aria-hidden={!!item.href && !item.ariaLabel}>
{item.node}
</span>
) : (
<img
src={item.src}
srcSet={item.srcSet}
sizes={item.sizes}
width={item.width}
height={item.height}
alt={item.alt ?? ''}
title={item.title}
loading="lazy"
decoding="async"
draggable={false}
/>
);
const itemAriaLabel = isNodeItem ? (item.ariaLabel ?? item.title) : (item.alt ?? item.title);
const itemContent = item.href ? (
<a
className="logoloop__link"
href={item.href}
aria-label={itemAriaLabel || 'logo link'}
target="_blank"
rel="noreferrer noopener"
>
{content}
</a>
) : (
content
);
return (
<li className="logoloop__item" key={key} role="listitem">
{itemContent}
</li>
);
}, []);
const logoLists = useMemo(
() =>
Array.from({ length: copyCount }, (_, copyIndex) => (
<ul
className="logoloop__list"
key={`copy-${copyIndex}`}
role="list"
aria-hidden={copyIndex > 0}
ref={copyIndex === 0 ? seqRef : undefined}
>
{logos.map((item, itemIndex) => renderLogoItem(item, `${copyIndex}-${itemIndex}`))}
</ul>
)),
[copyCount, logos, renderLogoItem]
);
const containerStyle = useMemo(
() => ({
width: toCssLength(width) ?? '100%',
...cssVariables,
...style
}),
[width, cssVariables, style]
);
return (
<div
ref={containerRef}
className={rootClassName}
style={containerStyle}
role="region"
aria-label={ariaLabel}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="logoloop__track" ref={trackRef}>
{logoLists}
</div>
</div>
);
});
LogoLoop.displayName = 'LogoLoop';
export default LogoLoop;