mirror of
https://github.com/katanemo/plano.git
synced 2026-04-25 16:56:24 +02:00
adding logo cloud component to katanemo-www (#718)
This commit is contained in:
parent
a278e67d9a
commit
8749593773
7 changed files with 870 additions and 4 deletions
608
apps/katanemo-www/src/components/LogoSlider.tsx
Normal file
608
apps/katanemo-www/src/components/LogoSlider.tsx
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue