Skip to content

Logo Carousel

Logos cycle through fixed slots with a blur swap.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/logo-carousel.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/logo-carousel/logo-carousel.tsx
    "use client";
    
    import { useEffect, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
    import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface LogoItem {
      /** Stable identity. */
      id: string;
      /** Company name, read by screen readers. */
      name: string;
      /** The logo artwork. */
      logo: ReactNode;
    }
    
    export interface LogoCarouselProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** All logos; they rotate through the slots. */
      logos: LogoItem[];
      /** Logos visible at once. @default 4 */
      slots?: number;
      /** Milliseconds between swaps. @default 2800 */
      interval?: number;
      /** Seconds between neighboring slots swapping. @default 0.12 */
      stagger?: number;
      /** Stop rotating. Rotation also pauses while hovered, focused or offscreen. @default false */
      paused?: boolean;
      /** Heading read before the logo names. @default "Trusted by" */
      label?: string;
    }
    
    export function LogoCarousel({
      logos,
      slots = 4,
      interval = 2800,
      stagger = 0.12,
      paused = false,
      label = "Trusted by",
      className,
      style,
      ...props
    }: LogoCarouselProps) {
      const reduceMotion = useReducedMotion();
      const ref = useRef<HTMLDivElement>(null);
      const inView = useInView(ref, { amount: 0.4 });
      const [start, setStart] = useState(0);
      const [held, setHeld] = useState(false);
      const count = Math.min(slots, logos.length);
    
      useEffect(() => {
        if (paused || held || !inView || logos.length <= count) return;
        const timer = window.setTimeout(() => setStart((s) => (s + count) % logos.length), interval);
        return () => window.clearTimeout(timer);
      }, [start, paused, held, inView, logos.length, count, interval]);
    
      const swap = {
        enter: reduceMotion ? { opacity: 0 } : { opacity: 0, y: 14, filter: "blur(8px)" },
        center: (i: number) => ({ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.slow, ease: ease.out, delay: i * stagger + 0.08 } }),
        exit: (i: number) => ({
          opacity: 0,
          ...(reduceMotion ? {} : { y: -14, filter: "blur(8px)" }),
          transition: { duration: duration.base, ease: ease.out, delay: i * stagger },
        }),
      };
    
      return (
        <div
          ref={ref}
          // Columns come from --slots, so a class like max-sm:grid-cols-2 can still rearrange them.
          className={cn("grid grid-cols-[repeat(var(--slots),minmax(0,1fr))] items-center gap-x-6 gap-y-8", className)}
          style={{ ...style, "--slots": count } as CSSProperties}
          onPointerEnter={(event) => event.pointerType === "mouse" && setHeld(true)}
          onPointerLeave={() => setHeld(false)}
          {...props}
        >
          <p className="sr-only">
            {label}: {logos.map((l) => l.name).join(", ")}
          </p>
          {Array.from({ length: count }, (_, slot) => {
            const logo = logos[(start + slot) % logos.length];
            return (
              // Each slot stacks its outgoing and incoming logo in one grid cell.
              <div key={slot} aria-hidden="true" className="grid h-12 place-items-center *:[grid-area:1/1]">
                <AnimatePresence initial={false} custom={slot}>
                  <motion.div
                    key={logo.id}
                    custom={slot}
                    variants={swap}
                    initial="enter"
                    animate="center"
                    exit="exit"
                    className="flex items-center justify-center text-muted-foreground"
                  >
                    {logo.logo}
                  </motion.div>
                </AnimatePresence>
              </div>
            );
          })}
        </div>
      );
    }

Usage

import { LogoCarousel } from "@/components/ui/logo-carousel/logo-carousel";

export function Customers() {
  return (
    <LogoCarousel
      slots={4}
      logos={[
        { id: "northwind", name: "Northwind", logo: <NorthwindLogo /> },
        // …more logos than slots
      ]}
    />
  );
}

API reference

PropTypeDefaultDescription
logos*LogoItem[]–All logos; they rotate through the slots.
slotsnumber4Logos visible at once.
intervalnumber2800Milliseconds between swaps.
staggernumber0.12Seconds between neighboring slots swapping.
pausedbooleanfalseStop rotating. Rotation also pauses while hovered, focused or offscreen.
labelstring"Trusted by"Heading read before the logo names.

Also accepts every native <div> attribute, including ref and className.

  • Each swap replaces every slot with the next logos in turn, staggered left to right, so no logo is ever shown twice at once.
  • Columns come from a --slots variable, so classes like max-sm:grid-cols-2 can rearrange the slots on small screens.

Accessibility

  • Screen readers hear every company name once, from a visually hidden sentence; the rotating artwork is aria-hidden.
  • Rotation pauses on hover and while offscreen; pass `paused` to stop it entirely.
  • With reduced motion enabled, logos crossfade without blur or movement.