Loading Carousel
Loading Carousel is a React and Tailwind CSS media component from Cult UI, licensed MIT. Copy it and paste it into your project.
Cult UI
MIT
media
What it is
Loading Carousel is a React and Tailwind CSS media component from Cult UI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i embla-carousel-autoplay lucide-react motion next
Licence
This component comes from Cult UI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Loading Carousel
// npm i embla-carousel-autoplay framer-motion lucide-react
// npx shadcn@latest add carousel
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Image from "next/image";
import Autoplay from "embla-carousel-autoplay";
import { ChevronRight } from "lucide-react";
import {
AnimatePresence,
motion,
useReducedMotion,
type Variants,
} from "motion/react";
import { cn } from "@/lib/utils";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
interface Tip {
text: string;
image: string;
url?: string;
}
interface LoadingCarouselProps {
tips?: Tip[];
className?: string;
autoplayInterval?: number;
showNavigation?: boolean;
showIndicators?: boolean;
showProgress?: boolean;
aspectRatio?: "video" | "square" | "wide";
textPosition?: "top" | "bottom";
onTipChange?: (index: number) => void;
backgroundTips?: boolean;
backgroundGradient?: boolean;
shuffleTips?: boolean;
}
const defaultTips: Tip[] = [
{
text: "Backend snippets. Shadcn style headless components.. but for your backend.",
image: "/placeholders/cult-snips.png",
url: "https://www.newcult.co/backend",
},
{
text: "Create your first directory app today. AI batch scripts to process 100s of urls in seconds.",
image: "/placeholders/cult-dir.png",
url: "https://www.newcult.co/templates/cult-seo",
},
{
text: "Cult landing page template. Framer motion, shadcn, and tailwind.",
image: "/placeholders/cult-rune.png",
url: "https://www.newcult.co/templates/cult-landing-page",
},
{
text: "Vector embeddings, semantic search, and chat based vector retrieval on easy mode.",
image: "/placeholders/cult-manifest.png",
url: "https://www.newcult.co/templates/manifest",
},
{
text: "SEO analysis app. Scraping, analysis, insights, and AI recommendations.",
image: "/placeholders/cult-seo.png",
url: "https://www.newcult.co/templates/cult-seo",
},
];
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
function getTipKey(tip: Tip): string {
return `${tip.text}-${tip.image}`;
}
const carouselVariants: Variants = {
enter: (direction: number) => ({
x: direction > 0 ? "100%" : "-100%",
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? "100%" : "-100%",
opacity: 0,
}),
};
const textVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { delay: 0.3, duration: 0.5 } },
};
const aspectRatioClasses = {
video: "aspect-video",
square: "aspect-square",
wide: "aspect-[2/1]",
};
export function LoadingCarousel({
onTipChange,
className,
tips = defaultTips,
showProgress = true,
aspectRatio = "video",
showNavigation = false,
showIndicators = true,
backgroundTips = false,
textPosition = "bottom",
autoplayInterval = 4500,
backgroundGradient = false,
shuffleTips = false,
}: LoadingCarouselProps) {
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [direction, setDirection] = useState(0);
const prefersReducedMotion = useReducedMotion();
const [displayTips] = useState(() =>
shuffleTips ? shuffleArray(tips) : tips,
);
const autoplay = useMemo(
() =>
Autoplay({
delay: autoplayInterval,
stopOnInteraction: false,
}),
[autoplayInterval],
);
useEffect(() => {
if (!api) {
return;
}
setCurrent(api.selectedScrollSnap());
setDirection(
api.scrollSnapList().indexOf(api.selectedScrollSnap()) - current,
);
const onSelect = () => {
const newIndex = api.selectedScrollSnap();
setCurrent(newIndex);
setDirection(api.scrollSnapList().indexOf(newIndex) - current);
onTipChange?.(newIndex);
};
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api, current, onTipChange]);
const handleSelect = useCallback(
(index: number) => {
api?.scrollTo(index);
},
[api],
);
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.8, ease: "easeOut" }
}
className={cn(
"mx-auto w-full max-w-6xl overflow-hidden rounded-xl bg-muted shadow-[0px_1px_1px_0px_rgba(0,0,0,0.05),0px_1px_1px_0px_rgba(255,252,240,0.5)_inset,0px_0px_0px_1px_hsla(0,0%,100%,0.1)_inset,0px_0px_1px_0px_rgba(28,27,26,0.5)]",
className,
)}
>
<div className="w-full overflow-hidden rounded-xl">
<Carousel
setApi={setApi}
plugins={[autoplay]}
className="relative w-full"
opts={{
loop: true,
}}
>
<CarouselContent>
<AnimatePresence initial={false} custom={direction}>
{(displayTips || []).map((tip) => (
<CarouselItem key={getTipKey(tip)} className="min-w-0">
<motion.div
variants={carouselVariants}
initial={prefersReducedMotion ? false : "enter"}
animate="center"
exit={prefersReducedMotion ? undefined : "exit"}
custom={direction}
transition={
prefersReducedMotion
? { duration: 0 }
: { duration: 0.8, ease: "easeInOut" }
}
className={`relative ${aspectRatioClasses[aspectRatio]} w-full overflow-hidden`}
>
<Image
src={tip.image}
alt={`Visual representation for tip: ${tip.text}`}
fill
className="object-cover"
priority
/>
{backgroundGradient && (
<div className="absolute inset-0 bg-linear-to-t from-black/90 via-black/20 to-transparent" />
)}
{backgroundTips ? (
<motion.div
variants={textVariants}
initial={prefersReducedMotion ? false : "hidden"}
animate="visible"
className={`absolute ${
textPosition === "top" ? "top-0" : "bottom-0"
} left-0 right-0 min-w-0 p-4 sm:p-6 md:p-8`}
>
{displayTips[current]?.url ? (
<a
href={displayTips[current]?.url}
target="_blank"
rel="noopener noreferrer"
className="block min-w-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<p className="wrap-break-word text-center text-base font-medium leading-relaxed tracking-tight text-white text-pretty sm:text-lg md:text-left md:text-xl lg:text-2xl lg:font-bold">
{tip.text}
</p>
</a>
) : (
<p className="wrap-break-word text-center text-base font-medium leading-relaxed tracking-tight text-white text-pretty sm:text-lg md:text-left md:text-xl lg:text-2xl lg:font-bold">
{tip.text}
</p>
)}
</motion.div>
) : null}
</motion.div>
</CarouselItem>
))}
</AnimatePresence>
</CarouselContent>
{showNavigation && (
<>
<CarouselPrevious className="absolute left-2 top-1/2 h-10 w-10 -translate-y-1/2 transition-transform active:scale-[0.96]" />
<CarouselNext className="absolute right-2 top-1/2 h-10 w-10 -translate-y-1/2 transition-transform active:scale-[0.96]" />
</>
)}
</Carousel>
<div
className={cn(
"bg-muted p-4 sm:p-5",
showIndicators && !backgroundTips ? "lg:px-4 lg:py-3" : "",
)}
>
<div
className={cn(
"flex min-w-0 flex-col items-start justify-between gap-3 sm:flex-row sm:items-center",
showIndicators && !backgroundTips
? "sm:flex-col sm:items-start"
: "",
)}
>
{showIndicators && (
<div className="flex w-full gap-2 overflow-x-auto pb-1 sm:pb-0">
{(displayTips || []).map((tip, index) => {
const isActive = index === current;
const isComplete = index < current;
return (
<button
key={getTipKey(tip)}
type="button"
className="flex h-10 min-w-8 flex-1 items-center rounded-full transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-muted active:scale-[0.96] sm:min-w-0 sm:shrink"
onClick={() => handleSelect(index)}
aria-label={`Go to tip ${index + 1}`}
aria-current={isActive ? "true" : undefined}
>
<span className="relative h-1 w-full overflow-hidden rounded-full bg-foreground/15">
{showProgress ? (
isComplete ? (
<span className="absolute inset-0 rounded-full bg-foreground/70" />
) : isActive ? (
<motion.span
key={current}
initial={{ scaleX: prefersReducedMotion ? 1 : 0 }}
animate={{ scaleX: 1 }}
transition={
prefersReducedMotion
? { duration: 0 }
: {
duration: autoplayInterval / 1000,
ease: "linear",
}
}
className="absolute inset-0 origin-left rounded-full bg-foreground/70"
/>
) : null
) : (
<span
className={cn(
"absolute inset-0 origin-left rounded-full bg-foreground/70 transition-transform",
prefersReducedMotion
? "duration-0"
: "duration-300 ease-out",
isActive ? "scale-x-100" : "scale-x-0",
)}
/>
)}
</span>
</button>
);
})}
</div>
)}
<div className="flex min-w-0 items-center gap-2 text-primary">
{backgroundTips ? (
<span className="whitespace-nowrap text-sm font-medium tabular-nums">
Tip {current + 1}/{displayTips?.length || 0}
</span>
) : (
<div className="min-w-0 max-w-full">
{displayTips[current]?.url ? (
<a
href={displayTips[current]?.url}
target="_blank"
rel="noopener noreferrer"
className="wrap-break-word block max-w-full rounded-sm text-base font-medium leading-tight tracking-tight text-pretty focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-muted lg:text-xl xl:font-semibold"
>
{displayTips[current]?.text}
</a>
) : (
<span className="wrap-break-word block max-w-full text-base font-medium leading-tight tracking-tight text-pretty lg:text-2xl xl:font-semibold">
{displayTips[current]?.text}
</span>
)}
</div>
)}
{backgroundTips && (
<ChevronRight aria-hidden="true" className="h-4 w-4" />
)}
</div>
</div>
</div>
</div>
</motion.div>
);
}
export default LoadingCarousel;