my/ui

Command Palette

Search for a command to run...

All components

Loading Carousel

carousels

Loading carousel component with smooth transitions and customizable content

responsive · 540px

Install

Same command in any shadcn project — Next.js:

$npx shadcn@latest add https://your-domain/r/loading-carousel.json

Usage

"use client"

import { LoadingCarousel } from "@/registry/cult-ui/loading-carousel"

const tips = [
  {
    text: "Build faster with shadcn-style headless components for your backend.",
    image: "https://picsum.photos/seed/lc1/1200/675",
  },
  {
    text: "Process hundreds of URLs in seconds with AI batch scripts.",
    image: "https://picsum.photos/seed/lc2/1200/675",
  },
  {
    text: "Framer Motion, shadcn, and Tailwind — the perfect landing page stack.",
    image: "https://picsum.photos/seed/lc3/1200/675",
  },
  {
    text: "Vector embeddings and semantic search made easy.",
    image: "https://picsum.photos/seed/lc4/1200/675",
  },
  {
    text: "SEO analysis: scraping, insights, and AI recommendations.",
    image: "https://picsum.photos/seed/lc5/1200/675",
  },
]

export default function Demo() {
  return <LoadingCarousel tips={tips} />
}

Component source

// 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-2xl 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

Dependencies

embla-carousel-autoplaylucide-reactmotion

Registry dependencies

carousel

Source: Cult UI