Created
May 16, 2025 08:29
-
-
Save b-bot/3da7ca43bad9148ea7425240089a58dc to your computer and use it in GitHub Desktop.
Infinite Testimonials
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client" | |
import { IconArrowRight, IconDiscountCheckFilled, IconQuote } from "@tabler/icons-react" | |
import React, { useEffect, useRef, useState } from "react" | |
import { Avatar, cn, Tooltip } from "@monokit/ui" | |
export const Testimonials = ({ | |
items, | |
pauseOnHover = true, | |
className, | |
}: { | |
items: { | |
quote: string | |
name: string | |
title: string | |
link: string | |
img: string | |
}[] | |
pauseOnHover?: boolean | |
className?: string | |
}) => { | |
const containerRef = useRef<HTMLDivElement>(null) | |
const scrollerRef = useRef<HTMLUListElement>(null) | |
const [start, setStart] = useState(false) | |
const [imagesLoaded, setImagesLoaded] = useState(false) | |
// Preload all images before starting animation | |
useEffect(() => { | |
const totalImages = items.length | |
let loadCounter = 0 | |
// Function to check if all images are loaded | |
const checkAllImagesLoaded = () => { | |
loadCounter += 1 | |
if (loadCounter === totalImages) { | |
setImagesLoaded(true) | |
} | |
} | |
// Create Image objects to preload all testimonial images | |
for (const item of items) { | |
const img = new Image() | |
img.src = item.img | |
img.addEventListener("load", () => { | |
checkAllImagesLoaded() | |
}) | |
img.addEventListener("error", () => { | |
checkAllImagesLoaded() | |
}) | |
} | |
// If there are no images, or if they're already cached, mark as loaded | |
if (totalImages === 0) { | |
setImagesLoaded(true) | |
} | |
}, [items]) | |
// Initialize animation after images are loaded | |
useEffect(() => { | |
// Move addAnimation function inside useEffect to prevent dependency changes on each render | |
function addAnimation() { | |
if (containerRef.current && scrollerRef.current) { | |
const scrollerContent = [...scrollerRef.current.children] | |
// Apply eager loading to all original images | |
for (const item of scrollerContent) { | |
const el = item | |
const avatarImg = el.querySelector("img") | |
if (avatarImg) { | |
avatarImg.setAttribute("loading", "eager") | |
avatarImg.setAttribute("fetchpriority", "high") | |
// Force image to be completely loaded | |
avatarImg.setAttribute("decoding", "sync") | |
} | |
} | |
// Clone items for infinite scroll effect | |
for (const item of scrollerContent) { | |
const duplicatedItem = item.cloneNode(true) | |
// Ensure cloned images also have proper loading attributes | |
if (duplicatedItem instanceof Element) { | |
const avatarImg = duplicatedItem.querySelector("img") | |
if (avatarImg) { | |
avatarImg.setAttribute("loading", "eager") | |
avatarImg.setAttribute("fetchpriority", "high") | |
avatarImg.setAttribute("decoding", "sync") | |
} | |
scrollerRef.current.append(duplicatedItem) | |
} | |
} | |
// Start the animation with a slight delay to ensure everything is rendered | |
setTimeout(() => { | |
setStart(true) | |
}, 100) | |
} | |
} | |
if (imagesLoaded) { | |
addAnimation() | |
} | |
}, [imagesLoaded]) | |
return ( | |
<div | |
ref={containerRef} | |
className={cn( | |
"relative z-20 max-w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,white_20%,white_80%,transparent)] dark:[mask-image:linear-gradient(to_right,transparent,black_20%,black_80%,transparent)]", | |
className, | |
)} | |
> | |
<ul | |
ref={scrollerRef} | |
className={cn( | |
"flex w-max min-w-full shrink-0 flex-nowrap gap-4 py-4", | |
start && "scroll", | |
pauseOnHover && "hover:[animation-play-state:paused]", | |
)} | |
> | |
{items.map((item) => ( | |
<Tooltip | |
key={item.name} | |
content={ | |
<span className="inline-flex items-center gap-1"> | |
<IconDiscountCheckFilled className="h-4 w-4 text-green-500" /> | |
Verified Testimonial | |
</span> | |
} | |
placement="top" | |
delay={0} | |
closeDelay={0} | |
> | |
<li className="border-primary from-primary to-primary-900 bg-linear-to-b relative w-[350px] max-w-full shrink-0 rounded-2xl border border-b-0 px-8 py-6 md:w-[450px]"> | |
<blockquote className="flex h-full flex-col justify-between"> | |
<IconQuote className="text-primary/50 absolute left-6 top-6 h-16 w-16 opacity-20" /> | |
<div | |
aria-hidden="true" | |
className="user-select-none -z-1 pointer-events-none absolute -left-0.5 -top-0.5 h-[calc(100%_+_4px)] w-[calc(100%_+_4px)]" | |
/> | |
<div className="relative"> | |
{" "} | |
<span className="text-primary-foreground relative z-20 text-sm font-normal leading-[1.6]"> | |
{item.quote} | |
</span> | |
<IconArrowRight | |
className="text-primary absolute -right-6 -top-4 z-20 h-6 w-6 -rotate-12 transform" | |
aria-hidden="true" | |
/> | |
</div> | |
<a | |
href={item.link} | |
target="_blank" | |
rel="noopener noreferrer" | |
className="relative z-20 mt-6 flex flex-row items-center transition-opacity hover:opacity-80" | |
> | |
<Avatar | |
isBordered | |
className="mr-3" | |
src={item.img} | |
name={item.name} | |
imgProps={{ | |
loading: "eager", | |
fetchPriority: "high", | |
onLoad: (e) => { | |
// Ensure image is visible by setting display properties | |
e.currentTarget.style.opacity = "1" | |
e.currentTarget.style.visibility = "visible" | |
}, | |
}} | |
/> | |
<span className="flex flex-col gap-1"> | |
<span className="text-primary-foreground/90 text-sm font-normal leading-[1.6]"> | |
{item.name} | |
</span> | |
<span className="text-primary-foreground/70 text-sm font-normal leading-[1.6]"> | |
{item.title} | |
</span> | |
</span> | |
</a> | |
</blockquote> | |
</li> | |
</Tooltip> | |
))} | |
</ul> | |
</div> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment