Todos os componentes

Loading Carousel

Loading Carousel é um componente de media para React e Tailwind CSS, da biblioteca Cult UI, com licença MIT. Copia e cola no teu projeto.

Cult UI MIT media

O que é

Loading Carousel é um componente de media para React e Tailwind CSS, da biblioteca Cult UI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i embla-carousel-autoplay lucide-react motion next

Licença

Este componente vem de Cult UI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Loading Carousel

apps/www/registry/default/ui/loading-carousel.tsx
// 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;

Componentes parecidos