{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating-carousel",
  "type": "registry:block",
  "title": "Rating Carousel",
  "description": "A Swiper testimonial row of rating cards: drag or swipe it, and off-window cards fade and blur as they leave. Autoplay pauses on hover, and dots jump to a card.",
  "dependencies": [
    "swiper"
  ],
  "registryDependencies": [],
  "cssVars": {
    "theme": {}
  },
  "files": [
    {
      "path": "components/tweenui/rating-carousel.tsx",
      "type": "registry:component",
      "target": "components/tweenui/rating-carousel.tsx",
      "content": "'use client';\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ComponentPropsWithoutRef,\n} from 'react';\nimport type { Swiper as SwiperClass } from 'swiper';\nimport { cn } from '@/lib/utils';\nimport 'swiper/css';\nimport { Autoplay } from 'swiper/modules';\nimport { Swiper, SwiperSlide } from 'swiper/react';\n\nconst prefersReducedMotion = () =>\n  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nexport interface TestimonialCardItem {\n  /** Stable id used as the React key. */\n  id: string;\n  /** Card heading above the quote. */\n  title: string;\n  /** Quote body. */\n  quote: string;\n  /** Person’s display name. */\n  name: string;\n  /** Role or title under the name. */\n  role: string;\n  /** Avatar URL. */\n  avatar: string;\n  /** Alt text for the avatar. */\n  avatarAlt: string;\n}\n\n/** Any Swiper option or event. Merged on top of the carousel defaults. */\nexport type TestimonialSwiperProps = Omit<ComponentProps<typeof Swiper>, 'children'>;\n\nexport interface RatingCarouselProps extends Omit<ComponentPropsWithoutRef<'section'>, 'title'> {\n  /** Cards shown in the carousel. Defaults to a 4-card sample. */\n  testimonials?: TestimonialCardItem[];\n  /** How many cards are visible once the tray is at least 560px wide. Narrower views show 1. Default 2. */\n  slidesPerView?: number;\n  /**\n   * Swiper options and events. Spread over the built-in defaults so speed, loop,\n   * autoplay, breakpoints, modules, and the rest stay configurable.\n   */\n  swiper?: TestimonialSwiperProps;\n}\n\nconst DEFAULT_TESTIMONIALS: TestimonialCardItem[] = [\n  {\n    id: 'cash-flow',\n    title: 'Improved Cash Flow Management',\n    quote:\n      'The dashboards are clear, automation saves us hours each week, and our team finally has full visibility into every financial movement.',\n    name: 'David K.',\n    role: 'CFO',\n    avatar:\n      'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=96&h=96&fit=crop&crop=faces',\n    avatarAlt: 'Portrait of David K.',\n  },\n  {\n    id: 'reporting',\n    title: 'Streamlined Financial Reporting',\n    quote:\n      'We can now generate comprehensive reports in minutes instead of days. Real-time analytics help us make data-driven decisions faster.',\n    name: 'Sarah M.',\n    role: 'Financial Controller',\n    avatar:\n      'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&fit=crop&crop=faces',\n    avatarAlt: 'Portrait of Sarah M.',\n  },\n  {\n    id: 'budget',\n    title: 'Enhanced Budget Planning',\n    quote:\n      'Budget planning used to live in spreadsheets. We create, track, and adjust budgets in real time, and the forecasting is accurate.',\n    name: 'Michael R.',\n    role: 'Finance Director',\n    avatar:\n      'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&fit=crop&crop=faces',\n    avatarAlt: 'Portrait of Michael R.',\n  },\n  {\n    id: 'invoices',\n    title: 'Automated Invoice Processing',\n    quote:\n      'We reduced processing time by 80% and eliminated manual errors. The system fits the tools we already use.',\n    name: 'Emily T.',\n    role: 'Accounts Manager',\n    avatar:\n      'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=96&h=96&fit=crop&crop=faces',\n    avatarAlt: 'Portrait of Emily T.',\n  },\n];\n\nconst StarIcon = ({ className }: { className?: string }) => {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 16 15\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className={className}\n    >\n      <path\n        d=\"M7.25672 0.486272C7.53172 -0.162091 8.46832 -0.162091 8.74332 0.486274L10.3663 4.31303C10.4823 4.58637 10.7445 4.77313 11.0454 4.79678L15.2582 5.12799C15.9719 5.18411 16.2614 6.05763 15.7175 6.51446L12.5079 9.2107C12.2786 9.40331 12.1784 9.70552 12.2485 9.99343L13.2291 14.0249C13.3952 14.7079 12.6375 15.2478 12.0264 14.8818L8.41965 12.7214C8.16202 12.5671 7.83802 12.5671 7.5804 12.7214L3.9736 14.8818C3.3625 15.2478 2.60477 14.7079 2.77091 14.0249L3.75155 9.99343C3.82159 9.70552 3.72147 9.40331 3.49221 9.2107L0.28245 6.51446C-0.261375 6.05763 0.0280544 5.18411 0.741835 5.12799L4.9547 4.79678C5.25561 4.77313 5.51774 4.58637 5.63367 4.31303L7.25672 0.486272Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n};\n\nfunction updateSlideStyles(swiper: SwiperClass) {\n  const reduced = prefersReducedMotion();\n  const perView = typeof swiper.params.slidesPerView === 'number' ? swiper.params.slidesPerView : 2;\n  const activeIndex = swiper.activeIndex;\n\n  swiper.slides.forEach((slide, index) => {\n    slide.style.transition = reduced ? 'none' : 'opacity 0.6s ease-out, filter 0.6s ease-out';\n\n    let offset = index - activeIndex;\n    if (offset < 0) offset += swiper.slides.length;\n\n    const inView = offset >= 0 && offset < perView;\n    slide.style.opacity = inView || reduced ? '1' : '0.3';\n    slide.style.filter = inView || reduced ? 'blur(0px)' : 'blur(30px)';\n  });\n}\n\nconst RatingCarousel = ({\n  testimonials = DEFAULT_TESTIMONIALS,\n  slidesPerView = 2,\n  swiper: swiperProps,\n  className,\n  ...props\n}: RatingCarouselProps) => {\n  const swiperRef = useRef<SwiperClass | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const reduced = prefersReducedMotion();\n  const count = testimonials.length;\n  const perView = Math.max(1, slidesPerView);\n  const loop = count > perView;\n  const instanceId = useId().replace(/:/g, '');\n\n  const {\n    className: swiperClassName,\n    modules: extraModules,\n    onSwiper,\n    autoplay: autoplayOverride,\n    speed: speedOverride,\n    ...restSwiper\n  } = swiperProps ?? {};\n\n  useEffect(() => {\n    const swiper = swiperRef.current;\n    if (!swiper) return;\n\n    const onChange = () => {\n      updateSlideStyles(swiper);\n      setActiveIndex(swiper.realIndex);\n    };\n\n    swiper.on('init', onChange);\n    swiper.on('slideChangeTransitionStart', onChange);\n    swiper.on('slideChange', onChange);\n    swiper.on('breakpoint', onChange);\n    onChange();\n\n    return () => {\n      swiper.off('init', onChange);\n      swiper.off('slideChangeTransitionStart', onChange);\n      swiper.off('slideChange', onChange);\n      swiper.off('breakpoint', onChange);\n    };\n  }, [perView, count]);\n\n  /**\n   * Swiper 14's `slideToLoop` is a no-op once the loop has rearranged its\n   * slides, and `slideTo` clamps rather than wrapping — neither can reach the\n   * last slide. Stepping is what actually moves a looped carousel, because each\n   * step runs `loopFix` first, so the hops before the final one are taken at\n   * zero duration and only the last one animates.\n   */\n  const goTo = (index: number) => {\n    const swiper = swiperRef.current;\n    if (!swiper) return;\n\n    if (!swiper.params.loop) {\n      setActiveIndex(index);\n      swiper.slideTo(index);\n      return;\n    }\n\n    let delta = (((index - swiper.realIndex) % count) + count) % count;\n    if (delta * 2 > count) delta -= count;\n    if (delta === 0) return;\n\n    const step = (speed?: number) =>\n      delta > 0 ? swiper.slideNext(speed) : swiper.slidePrev(speed);\n    for (let remaining = Math.abs(delta); remaining > 1; remaining -= 1) step(0);\n    step();\n  };\n\n  return (\n    <section\n      data-rating-carousel\n      data-active-slide={activeIndex}\n      data-slides-per-view={perView}\n      className={cn('w-full', className)}\n      {...props}\n    >\n      <div className=\"mx-auto w-full max-w-[1290px] min-w-0 space-y-8\">\n        <div\n          className=\"overflow-hidden rounded-3xl bg-[#045f64]/5 p-1 dark:bg-[#045f64]/20\"\n          onMouseEnter={() => swiperRef.current?.autoplay?.pause()}\n          onMouseLeave={() => swiperRef.current?.autoplay?.resume()}\n        >\n          <Swiper\n            grabCursor\n            allowTouchMove\n            observer\n            observeParents\n            watchOverflow\n            spaceBetween={4}\n            loop={loop}\n            slidesPerView={1}\n            breakpointsBase=\"container\"\n            breakpoints={{ 560: { slidesPerView: perView } }}\n            {...restSwiper}\n            className={cn(\n              'w-full min-w-0 cursor-grab select-none active:cursor-grabbing',\n              swiperClassName\n            )}\n            modules={[Autoplay, ...(extraModules ?? [])]}\n            speed={reduced ? 0 : (speedOverride ?? 1100)}\n            autoplay={\n              reduced || count < 2\n                ? false\n                : (autoplayOverride ?? { delay: 2500, disableOnInteraction: false })\n            }\n            onSwiper={(instance) => {\n              swiperRef.current = instance;\n              updateSlideStyles(instance);\n              setActiveIndex(instance.realIndex);\n              onSwiper?.(instance);\n            }}\n          >\n            {testimonials.map((item) => (\n              <SwiperSlide key={item.id} className=\"!h-auto\">\n                <article\n                  data-testimonial-card\n                  className=\"flex h-[260px] flex-col items-start justify-between rounded-[20px] bg-white px-6 py-6 motion-reduce:blur-none md:h-[280px] md:px-7 md:py-7 dark:bg-[#12161F]\"\n                >\n                  <div className=\"space-y-3\">\n                    <div className=\"flex w-[104px] items-center justify-center gap-x-0.5 rounded-[30px] bg-[#045f64]/10 px-2 py-1.5 text-[#045f64] dark:bg-[#045f64]/20 dark:text-[#9fd4d6]\">\n                      {Array.from({ length: 5 }).map((_, starIndex) => (\n                        <StarIcon key={starIndex} className=\"size-4\" />\n                      ))}\n                    </div>\n                    <div className=\"space-y-2\">\n                      <h3 className=\"text-base font-medium text-[#12161F] md:text-lg dark:text-white\">\n                        {item.title}\n                      </h3>\n                      <p className=\"line-clamp-3 text-sm leading-relaxed text-[#045f64]/80 dark:text-[#9fd4d6]/80\">\n                        &ldquo;{item.quote}&rdquo;\n                      </p>\n                    </div>\n                  </div>\n\n                  <div className=\"mt-4 flex w-full max-w-[240px] items-center gap-x-3 rounded-full bg-[#045f64]/10 py-1.5 pr-5 pl-1.5 dark:bg-[#045f64]/20\">\n                    <figure className=\"size-10 overflow-hidden rounded-full md:size-11\">\n                      <img\n                        src={item.avatar}\n                        alt={item.avatarAlt}\n                        className=\"size-full object-cover\"\n                        draggable={false}\n                      />\n                    </figure>\n                    <div>\n                      <p className=\"text-sm font-semibold text-[#045f64] dark:text-[#9fd4d6]\">\n                        {item.name}\n                      </p>\n                      <p className=\"text-xs text-[#045f64]/60 dark:text-[#9fd4d6]/80\">\n                        {item.role}\n                      </p>\n                    </div>\n                  </div>\n                </article>\n              </SwiperSlide>\n            ))}\n          </Swiper>\n        </div>\n\n        <div\n          id={`${instanceId}-pagination`}\n          className=\"mx-auto flex h-6 w-fit items-center justify-center gap-2 rounded-[56px] bg-[#045f64] px-3\"\n        >\n          {testimonials.map((item, index) => (\n            <button\n              key={item.id}\n              type=\"button\"\n              aria-label={`Go to ${item.name}'s testimonial`}\n              aria-current={index === activeIndex ? 'true' : undefined}\n              onClick={() => goTo(index)}\n              className={cn(\n                'size-2 cursor-pointer rounded-full transition-colors motion-reduce:transition-none',\n                'focus-visible:ring-2 focus-visible:ring-[#c6f56f] focus-visible:ring-offset-2 focus-visible:ring-offset-[#045f64] focus-visible:outline-none',\n                index === activeIndex ? 'bg-[#c6f56f]' : 'bg-white/35 hover:bg-white/70'\n              )}\n            />\n          ))}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nexport default RatingCarousel;\n"
    }
  ],
  "docs": "Docs, props and examples: https://tween-ui.vercel.app/block/rating-carousel\nThe sample content loads images from images.unsplash.com. Replace them with your own before shipping."
}