Back

Spinning Testimonials

A sleek, reusable testimonial carousel that smoothly spins through any number of testimonials. Designed to be fully responsive, it automatically converts its direct children into carousel slides, making it perfect for highlighting customer feedback in a compact, eye-catching way.

Current Theme:
"This SaaS cut our onboarding time from days to hours, all without messy spreadsheets."
GR
Guillermo Rauch
CEO / Vercel
"The dashboard delivers real-time insights, helping us make faster, smarter decisions."
TB
Theo Browne
CEO / Ping Labs
"We replaced three tools with this one. It’s clean, intuitive, and a joy to use.We replaced three tools with this one. It’s clean, intuitive, and a joy to use."
KCD
Kent C. Dodds
Frontend Educator
"Support is fast, friendly, and the product keeps getting better with each update."
CN
shadcn
Creator of shadcn/ui

API usage

components/spinning-testimonials.demo.tsx
import { SpinningCarousel } from "@/components/spinning-carousel";
import {
  TestimonialCard,
  TestimonialContent,
  TestimonialAvatar,
  TestimonialAvatarFallback,
  TestimonialAvatarImage,
  TestimonialName,
  TestimonialPosition,
  TestimonialAuthor,
} from "@/components/ui/testimonial";

export function SpinningTestimonialsDemo() {
  return (
    <SpinningCarousel className="h-[calc(500px_-_30vw)] min-h-60 lg:h-[calc(400px_-_7vw)]">

      {TESTIMONIALS.map(({ name, testimonial, position, src, fallback }) => (
        <TestimonialCard key={name}>

          <TestimonialContent className="-indent-1.5 lg:-indent-2">
            &quot;{testimonial}&quot;
          </TestimonialContent>
          
          <TestimonialAuthor>
            <TestimonialAvatar className="size-10">
              <TestimonialAvatarImage src={src} />
              <TestimonialAvatarFallback>{fallback}</TestimonialAvatarFallback>
            </TestimonialAvatar>
            <TestimonialName>{name}</TestimonialName>
            <TestimonialPosition>{position}</TestimonialPosition>
          </TestimonialAuthor>

        </TestimonialCard>
      ))}
        
    </SpinningCarousel>
  );
}

const TESTIMONIALS = [
  {
    testimonial:
      "This SaaS cut our onboarding time from days to hours, all without messy spreadsheets.",
    name: "Guillermo Rauch",
    position: "CEO / Vercel",
    src: "https://github.com/rauchg.png",
    fallback: "GR",
  },
  {
    testimonial:
      "The dashboard delivers real-time insights, helping us make faster, smarter decisions.",
    name: "Theo Browne",
    position: "CEO / Ping Labs",
    src: "https://github.com/t3dotgg.png",
    fallback: "TB",
  },
  {
    testimonial:
      "We replaced three tools with this one. It’s clean, intuitive, and a joy to use.We replaced three tools with this one. It’s clean, intuitive, and a joy to use.",
    name: "Kent C. Dodds",
    position: "Frontend Educator",
    src: "https://github.com/kentcdodds.png",
    fallback: "KCD",
  },
  {
    testimonial:
      "Support is fast, friendly, and the product keeps getting better with each update.",
    name: "shadcn",
    position: "Creator of shadcn/ui",
    src: "https://github.com/shadcn.png",
    fallback: "CN",
  },
  {
    name: "Paul Copperstone",
    position: "CEO / Supabase",
    testimonial:
      "Setup took less than a day, and productivity improved immediately across teams.",
    src: "https://github.com/kiwicopple.png",
    fallback: "PC",
  },
];

Installation

CLI (recommended)

1
Run the command below to add the component to your project.
It will also generate the required base stylesheet if one doesn't already exist and guide you through setting up the import alias @/components/... if it isn't already configured.
pnpm dlx shadcn@latest add https://100xui.com/components/spinning-testimonials.json

Manual

1
Install the following dependencies.
pnpm add @radix-ui/react-avatar clsx motion tailwind-merge
2
Copy and paste the following code into your project. To apply your own styles, simply skip copying the stylesheet.
100xui
components/spinning-carousel.tsx
"use client";

import React from "react";
import {
  cubicBezier,
  motion,
  MotionConfig,
  type HTMLMotionProps,
} from "motion/react";

import { cn } from "@/lib/utils";
import { useInViewInterval } from "@/hooks/use-in-view-interval";

const CAROUSEL_CARD_POSITIONS = [
  { opacity: 0.5, zIndex: 2, x: "-100%" },
  { opacity: 1, zIndex: 3, x: "0%" },
  { opacity: 0.5, zIndex: 2, x: "100%" },
  { opacity: 0, zIndex: 1, x: "0%" },
];

const TOTAL_CARDS = 4;

export interface SpinningCarouselProps
  extends React.ComponentPropsWithoutRef<"div"> {
  children: React.ReactElement[];
  readTimeInSec?: number;
  animationDurationInSec?: number;
}

export function SpinningCarousel({
  children,
  readTimeInSec = 4,
  animationDurationInSec = 1,
  className,
  ...rest
}: SpinningCarouselProps) {
  const totalChildren = children.length;

  const [carouselState, setCarouselState] = React.useState<{
    index: number;
    visibleCardIndices: number[];
  }>({
    index: 0,
    visibleCardIndices: Array.from(
      { length: TOTAL_CARDS },
      (_, i) => i % totalChildren,
    ),
  });

  const containerRef = React.useRef<HTMLDivElement>(null);

  const handleInterval = React.useCallback(() => {
    setCarouselState(({ index, visibleCardIndices }) => {
      const nextIndex = index + 1;

      const updatedVisibleIndices = visibleCardIndices.map((cardIndex, i) =>
        (i - (nextIndex % TOTAL_CARDS) + TOTAL_CARDS) % TOTAL_CARDS === 2
          ? (index + TOTAL_CARDS - 1) % totalChildren
          : cardIndex,
      );

      return {
        index: nextIndex,
        visibleCardIndices: updatedVisibleIndices,
      };
    });
  }, [totalChildren]);

  useInViewInterval(
    containerRef,
    handleInterval,
    (readTimeInSec + animationDurationInSec) * 1000,
  );

  return (
    <div
      ref={containerRef}
      className={cn(
        "w-full",
        className,
        "!grid !grid-cols-7 !overflow-hidden lg:!grid-cols-4 lg:py-3",
      )}
      {...rest}
    >
      <MotionConfig
        transition={{
          duration: animationDurationInSec,
          ease: cubicBezier(0.08, 0.82, 0.17, 1),
        }}
      >
        {carouselState.visibleCardIndices.map((cardIndex, i) => {
          const positionIndex =
            (i - (carouselState.index % TOTAL_CARDS) + TOTAL_CARDS) %
            TOTAL_CARDS;

          return (
            <SpinningCarouselCard
              key={`spinningCarouselCard[${i}]`}
              initial={CAROUSEL_CARD_POSITIONS[i]}
              animate={CAROUSEL_CARD_POSITIONS[positionIndex]}
            >
              {children[cardIndex]}
            </SpinningCarouselCard>
          );
        })}
      </MotionConfig>
    </div>
  );
}

function SpinningCarouselCard(props: HTMLMotionProps<"div">) {
  return (
    <motion.div
      {...props}
      className="col-span-5 col-start-2 row-start-1 grid px-2 lg:col-span-2 lg:col-start-2 lg:px-3"
    />
  );
}
3
Finally, Update the import paths to match your project setup.

API reference

<SpinningCarousel/>

PropsTypeDescriptionDefault value
children
React.ReactElement[]
An array of  ReactElements to be rendered as individual carousel cards.
(required)
animationDurationInSec?
number
Duration (in seconds) of the transition animation between carousel cards.
1
readTimeInSec?
number
Time (in seconds) each card stays visible before moving to the next. This allows the user time to read and view the content.
4
...rest
React.ComponentPropsWithoutRef<"div">
Any standard React div props, like id, style or className, which will be applied directly to the component's root element except for ref.
undefined

<TestimonialCard/><TestimonialContent/><TestimonialAuthor/><TestimonialName/><TestimonialPosition/>

PropsTypeDescriptionDefault value
props
React.ComponentProps<"div">
Any standard React div props, like children, id, style or className, which will be applied directly to the component's root element.
undefined

<TestimonialAvatar/><TestimonialAvatarImage/><TestimonialAvatarFallback/>

These components wrap shadcn/ui's <Avatar/><AvatarImage/>, and <AvatarFallback/>, respectively, applying custom styling for the testimonial layout. See the API reference for details .
Caveats:
  • You need to explicitly set the height of the <SpinningCarousel/> based on the tallest card to prevent layout shifts when a newly rendered card requires more vertical space than currently available.
  • Set the height at two breakpoints (base: and lg:) as shown in the demo, because the card’s width relative to its parent changes at the lg breakpoint.