plano/apps/katanemo-www/src/components/LogoSlider.tsx

608 lines
18 KiB
TypeScript

"use client";
import Image from "next/image";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type LogoItem =
| {
node: React.ReactNode;
href?: string;
title?: string;
ariaLabel?: string;
}
| {
src: string;
alt?: string;
href?: string;
title?: string;
srcSet?: string;
sizes?: string;
width?: number;
height?: number;
};
export interface LogoLoopProps {
logos: LogoItem[];
speed?: number;
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;
}
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
} as const;
const toCssLength = (value?: number | string): string | undefined =>
typeof value === "number" ? `${value}px` : (value ?? undefined);
const cx = (...parts: Array<string | false | null | undefined>) =>
parts.filter(Boolean).join(" ");
const isNodeItem = (
item: LogoItem,
): item is Extract<LogoItem, { node: React.ReactNode }> => "node" in item;
const isImageItem = (
item: LogoItem,
): item is Extract<LogoItem, { src: string }> => "src" in item;
const useResizeObserver = (
callback: () => void,
elements: Array<React.RefObject<Element | null>>,
dependencies: React.DependencyList,
) => {
useEffect(() => {
if (!window.ResizeObserver) {
const handleResize = () => callback();
window.addEventListener("resize", handleResize);
callback();
return () => window.removeEventListener("resize", handleResize);
}
const observers: Array<ResizeObserver | null> = [];
for (const ref of elements) {
if (!ref.current) {
observers.push(null);
continue;
}
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
observers.push(observer);
}
callback();
return () => {
for (const observer of observers) {
observer?.disconnect();
}
};
}, [callback, elements, ...elements, ...dependencies]);
};
const useImageLoader = (
seqRef: React.RefObject<HTMLUListElement | null>,
onLoad: () => void,
dependencies: React.DependencyList,
) => {
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);
});
};
}, [onLoad, seqRef, ...dependencies]);
};
const useAnimationLoop = (
trackRef: React.RefObject<HTMLDivElement | null>,
targetVelocity: number,
seqWidth: number,
seqHeight: number,
isHovered: boolean,
hoverSpeed: number | undefined,
isVertical: 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;
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;
const transformValue = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
track.style.transform = transformValue;
}
if (prefersReduced) {
track.style.transform = isVertical
? "translate3d(0, 0, 0)"
: "translate3d(0, 0, 0)";
return () => {
lastTimestampRef.current = null;
};
}
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 =
isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
const easingFactor =
1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
if (seqSize > 0) {
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
offsetRef.current = nextOffset;
const transformValue = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
track.style.transform = transformValue;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTimestampRef.current = null;
};
}, [
trackRef,
targetVelocity,
seqWidth,
seqHeight,
isHovered,
hoverSpeed,
isVertical,
]);
};
export const LogoLoop = React.memo<LogoLoopProps>(
({
logos,
speed = 120,
direction = "left",
width = "100%",
logoHeight = 28,
gap = 32,
pauseOnHover,
hoverSpeed,
fadeOut = false,
fadeOutColor,
scaleOnHover = false,
renderItem,
ariaLabel = "Partner logos",
className,
style,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const seqRef = useRef<HTMLUListElement>(null);
const [seqWidth, setSeqWidth] = useState<number>(0);
const [seqHeight, setSeqHeight] = useState<number>(0);
const [copyCount, setCopyCount] = useState<number>(
ANIMATION_CONFIG.MIN_COPIES,
);
const [isHovered, setIsHovered] = useState<boolean>(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);
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, isVertical]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const sequenceRect = seqRef.current?.getBoundingClientRect?.();
const sequenceWidth = sequenceRect?.width ?? 0;
const sequenceHeight = sequenceRect?.height ?? 0;
if (isVertical) {
const parentHeight =
containerRef.current?.parentElement?.clientHeight ?? 0;
if (containerRef.current && parentHeight > 0) {
const targetHeight = Math.ceil(parentHeight);
if (containerRef.current.style.height !== `${targetHeight}px`)
containerRef.current.style.height = `${targetHeight}px`;
}
if (sequenceHeight > 0) {
setSeqHeight(Math.ceil(sequenceHeight));
const viewport =
containerRef.current?.clientHeight ??
parentHeight ??
sequenceHeight;
const copiesNeeded =
Math.ceil(viewport / 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, isVertical],
);
useImageLoader(seqRef, updateDimensions, [
logos,
gap,
logoHeight,
isVertical,
]);
useAnimationLoop(
trackRef,
targetVelocity,
seqWidth,
seqHeight,
isHovered,
effectiveHoverSpeed,
isVertical,
);
const cssVariables = useMemo(
() =>
({
"--logoloop-gap": `${gap}px`,
"--logoloop-logoHeight": `${logoHeight}px`,
...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }),
}) as React.CSSProperties,
[gap, logoHeight, fadeOutColor],
);
const rootClasses = useMemo(
() =>
cx(
"relative group",
isVertical
? "overflow-hidden h-full inline-block"
: "overflow-x-hidden",
"[--logoloop-gap:32px]",
"[--logoloop-logoHeight:28px]",
"[--logoloop-fadeColorAuto:#ffffff]",
"dark:[--logoloop-fadeColorAuto:#0b0b0b]",
scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]",
className,
),
[isVertical, scaleOnHover, className],
);
const handleMouseEnter = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
}, [effectiveHoverSpeed]);
const handleMouseLeave = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
}, [effectiveHoverSpeed]);
const renderLogoItem = useCallback(
(item: LogoItem, key: React.Key) => {
if (renderItem) {
return (
<li
className={cx(
"flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]",
isVertical
? "mb-[var(--logoloop-gap)]"
: "mr-[var(--logoloop-gap)]",
scaleOnHover && "overflow-visible group/item",
)}
key={key}
>
{renderItem(item, key)}
</li>
);
}
const content = isNodeItem(item) ? (
<span
className={cx(
"inline-flex items-center",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
aria-hidden={!!item.href && !item.ariaLabel}
>
{item.node}
</span>
) : (
<Image
className={cx(
"h-[var(--logoloop-logoHeight)] w-auto block object-contain",
"[-webkit-user-drag:none] pointer-events-none",
"[image-rendering:-webkit-optimize-contrast]",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
src={item.src}
sizes={item.sizes}
width={item.width ?? 120}
height={item.height ?? 32}
alt={item.alt ?? ""}
title={item.title}
draggable={false}
/>
);
const itemAriaLabel = isNodeItem(item)
? (item.ariaLabel ?? item.title)
: (item.alt ?? item.title);
const inner = item.href ? (
<a
className={cx(
"inline-flex items-center no-underline rounded",
"transition-opacity duration-200 ease-linear",
"hover:opacity-80",
"focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2",
)}
href={item.href}
aria-label={itemAriaLabel || "logo link"}
target="_blank"
rel="noreferrer noopener"
>
{content}
</a>
) : (
content
);
return (
<li
className={cx(
"flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]",
isVertical
? "mb-[var(--logoloop-gap)]"
: "mr-[var(--logoloop-gap)]",
scaleOnHover && "overflow-visible group/item",
)}
key={key}
>
{inner}
</li>
);
},
[isVertical, scaleOnHover, renderItem],
);
const logoLists = useMemo(
() =>
Array.from({ length: copyCount }, (_, copyIndex) => (
<ul
className={cx("flex items-center", isVertical && "flex-col")}
// biome-ignore lint/suspicious/noArrayIndexKey: Static copies for animation.
key={`copy-${copyIndex}`}
aria-hidden={copyIndex > 0}
ref={copyIndex === 0 ? seqRef : undefined}
>
{logos.map((item, itemIndex) =>
renderLogoItem(item, `${copyIndex}-${itemIndex}`),
)}
</ul>
)),
[copyCount, logos, renderLogoItem, isVertical],
);
const containerStyle = useMemo(
(): React.CSSProperties => ({
width: isVertical
? toCssLength(width) === "100%"
? undefined
: toCssLength(width)
: (toCssLength(width) ?? "100%"),
...cssVariables,
...style,
}),
[width, cssVariables, style, isVertical],
);
return (
<section
ref={containerRef}
className={rootClasses}
style={containerStyle}
aria-label={ariaLabel}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{fadeOut &&
(isVertical ? (
<>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 top-0 z-10",
"h-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_bottom,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 bottom-0 z-10",
"h-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_top,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
</>
) : (
<>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-10",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-10",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
</>
))}
<div
className={cx(
"flex will-change-transform select-none relative z-0",
"motion-reduce:transform-none",
isVertical ? "flex-col h-max w-full" : "flex-row w-max",
)}
ref={trackRef}
>
{logoLists}
</div>
</section>
);
},
);
LogoLoop.displayName = "LogoLoop";
const logos: LogoItem[] = [
{ src: "/logos/chase.svg", alt: "Chase" },
{ src: "/logos/hp.svg", alt: "HP" },
{ src: "/logos/huggingface.svg", alt: "Hugging Face" },
{ src: "/logos/sandisk.svg", alt: "SanDisk" },
{ src: "/logos/tmobile.svg", alt: "T-Mobile" },
];
export default function LogoSlider() {
return (
<div className="mt-5 w-full max-w-90 sm:max-w-152">
<LogoLoop
logos={logos}
speed={40}
logoHeight={17}
gap={30}
fadeOut={false}
renderItem={(item, key) => {
if (isImageItem(item)) {
return (
<Image
key={key}
src={item.src}
alt={item.alt ?? ""}
width={item.width ?? 120}
height={item.height ?? 32}
className="h-[var(--logoloop-logoHeight)] w-auto opacity-70 brightness-0 invert"
draggable={false}
/>
);
}
return <span key={key}>{item.node}</span>;
}}
className="relative"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
maskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
}}
/>
</div>
);
}