Skip to content

Instantly share code, notes, and snippets.

@b-bot
Created May 16, 2025 08:29
Show Gist options
  • Save b-bot/3da7ca43bad9148ea7425240089a58dc to your computer and use it in GitHub Desktop.
Save b-bot/3da7ca43bad9148ea7425240089a58dc to your computer and use it in GitHub Desktop.
Infinite Testimonials
"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