Logo Loop
An infinite horizontal logo carousel with pause-on-hover and 3D tilt effects.
Overview
A smooth infinite logo carousel that automatically calculates required duplicates for seamless looping. Features pause-on-hover, adjustable speed, and individual logo tilt effects.
Features
- Automatic seamless looping with dynamic duplication
- Configurable scroll speed and direction
- Pause on hover with smooth velocity transitions
- Per-logo 3D tilt on mouse interaction
- Glint effect on hover
Preview
Compute
Edge
Fast
Secure
Storage
Cloud
Compute
Edge
Fast
Secure
Storage
Cloud
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/logo-loop.jsonMethod 2: Manual
-
Install Dependencies
npm install framer-motion lucide-react -
Copy the Source Code
Copy the code below into
components/zenblocks/logo-loop.tsx.Click to expand source
"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { motion, AnimatePresence, useSpring, useMotionValue, useTransform } from 'framer-motion'; import { cn } from '@/lib/utils'; import { Cpu, Globe, Zap, Shield, Database, Cloud } from "lucide-react"; export type LogoItem = { src?: string; node?: React.ReactNode; alt?: string; name?: string; }; export const DEFAULT_LOGOS: LogoItem[] = [ { node: <Cpu size={20} className="text-blue-500" />, name: "Compute" }, { node: <Globe size={20} className="text-emerald-500" />, name: "Edge" }, { node: <Zap size={20} className="text-yellow-500" />, name: "Fast" }, { node: <Shield size={20} className="text-purple-500" />, name: "Secure" }, { node: <Database size={20} className="text-orange-500" />, name: "Storage" }, { node: <Cloud size={20} className="text-cyan-500" />, name: "Cloud" }, ]; interface LogoLoopProps { items?: LogoItem[]; speed?: number; direction?: 'left' | 'right'; gap?: number; logoHeight?: number; pauseOnHover?: boolean; className?: string; } const ANIMATION_CONFIG = { SMOOTH_TAU: 0.2, // Faster response than reference MIN_COPIES: 2, } as const; /* -------------------------------------------------------------------------- */ /* LOGO BOX COMPONENT */ /* -------------------------------------------------------------------------- */ const LogoBox = ({ item, height }: { item: LogoItem; height: number }) => { const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [15, -15]), { stiffness: 300, damping: 30 }); const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-15, 15]), { stiffness: 300, damping: 30 }); const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { const rect = e.currentTarget.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width - 0.5; const y = (e.clientY - rect.top) / rect.height - 0.5; mouseX.set(x); mouseY.set(y); }; const handleMouseLeave = () => { mouseX.set(0); mouseY.set(0); }; return ( <motion.div onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} style={{ rotateX, rotateY, height, perspective: 1000 }} className="relative flex items-center justify-center px-8 py-4 bg-zinc-50 dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-2xl group/logo transition-colors hover:bg-white dark:hover:bg-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 shadow-sm hover:shadow-xl group" > {/* Glint Effect */} <motion.div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none overflow-hidden rounded-2xl" > <div className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[200%] transition-transform duration-1000 ease-in-out" /> </motion.div> {item.node ? ( <div className="flex items-center gap-3"> {item.node} {item.name && <span className="text-xs font-black uppercase tracking-widest text-zinc-400 group-hover:text-zinc-900 dark:group-hover:text-white transition-colors">{item.name}</span>} </div> ) : ( <img src={item.src} alt={item.alt || "logo"} className="h-full w-auto grayscale group-hover:grayscale-0 transition-all duration-500 opacity-50 group-hover:opacity-100 scale-90 group-hover:scale-100" /> )} </motion.div> ); }; /* -------------------------------------------------------------------------- */ /* MAIN LOGO LOOP */ /* -------------------------------------------------------------------------- */ export const LogoLoop = ({ items = DEFAULT_LOGOS, speed = 100, // px per second direction = 'left', gap = 24, logoHeight = 40, pauseOnHover = true, className }: LogoLoopProps) => { const containerRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null); const seqRef = useRef<HTMLDivElement>(null); const [seqWidth, setSeqWidth] = useState(0); const [copyCount, setCopyCount] = useState(2); const [isHovered, setIsHovered] = useState(false); // Animation State const offsetRef = useRef(0); const lastTimeRef = useRef<number | null>(null); const velocityRef = useRef(speed * (direction === 'left' ? 1 : -1)); // Determine copies needed via ResizeObserver useEffect(() => { if (!containerRef.current || !seqRef.current) return; const update = () => { if (!containerRef.current || !seqRef.current) return; const cWidth = containerRef.current.offsetWidth; const sWidth = seqRef.current.offsetWidth + gap; if (sWidth > 0) { setSeqWidth(sWidth); const needed = Math.ceil(cWidth / sWidth) + 2; setCopyCount(Math.max(2, needed)); } }; const observer = new ResizeObserver(() => { // Debounce or just call update requestAnimationFrame(update); }); observer.observe(containerRef.current); if (seqRef.current) observer.observe(seqRef.current); // Initial call update(); return () => observer.disconnect(); }, [items, gap]); // Main RAF Loop useEffect(() => { let animationFrameId: number; lastTimeRef.current = null; // Reset time on effect restart const animate = (time: number) => { if (lastTimeRef.current === null) { lastTimeRef.current = time; animationFrameId = requestAnimationFrame(animate); return; } const delta = (time - lastTimeRef.current) / 1000; lastTimeRef.current = time; // Smooth velocity transitions const targetVel = isHovered && pauseOnHover ? 0 : speed * (direction === 'left' ? 1 : -1); const easing = 1 - Math.exp(-delta / ANIMATION_CONFIG.SMOOTH_TAU); velocityRef.current += (targetVel - velocityRef.current) * easing; if (seqWidth > 0 && trackRef.current) { offsetRef.current += velocityRef.current * delta; // Wrap offset offsetRef.current = (offsetRef.current % seqWidth + seqWidth) % seqWidth; trackRef.current.style.transform = `translate3d(${-offsetRef.current}px, 0, 0)`; } animationFrameId = requestAnimationFrame(animate); }; animationFrameId = requestAnimationFrame(animate); return () => cancelAnimationFrame(animationFrameId); }, [seqWidth, isHovered, speed, direction, pauseOnHover]); return ( <div ref={containerRef} className={cn("relative w-full overflow-hidden group/loop py-10", className)} > {/* Masking Gradients */} <div className="absolute inset-y-0 left-0 w-32 bg-gradient-to-r from-white dark:from-zinc-950 to-transparent z-10 pointer-events-none" /> <div className="absolute inset-y-0 right-0 w-32 bg-gradient-to-l from-white dark:from-zinc-950 to-transparent z-10 pointer-events-none" /> <div ref={trackRef} className="flex w-max" style={{ gap }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {Array.from({ length: copyCount }).map((_, i) => ( <div key={i} ref={i === 0 ? seqRef : null} className="flex shrink-0" style={{ gap }} > {items.map((item, idx) => ( <LogoBox key={idx} item={item} height={logoHeight} /> ))} </div> ))} </div> </div> ); };
Usage
import { LogoLoop } from "@/components/zenblocks/logo-loop";
import { Cpu, Globe } from "lucide-react";
const logos = [
{ node: <Cpu size={20} />, name: "Compute" },
{ node: <Globe size={20} />, name: "Edge" }
];
<LogoLoop items={logos} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | LogoItem[] | DEFAULT_LOGOS | Array of logo objects |
| speed | number | 100 | Scroll speed in px/second |
| direction | "left" | "right" | "left" | Scroll direction |
| gap | number | 24 | Space between logos in px |
| logoHeight | number | 40 | Logo container height |
| pauseOnHover | boolean | true | Pause scrolling on hover |
| className | string | - | Additional CSS classes |
LogoItem
| Property | Type | Description |
|---|---|---|
| src | string | Image URL (for image logos) |
| node | ReactNode | React component (for icon logos) |
| alt | string | Alt text for images |
| name | string | Label text for icons |
Accessibility
- Logo images include alt text
- Icon labels are visible on hover
- Keyboard users can tab through logos
- Respects
prefers-reduced-motionfor scroll animation
Customization
Speed and Direction
<LogoLoop speed={50} direction="right" />Custom Gap
<LogoLoop gap={48} />Disable Pause
<LogoLoop pauseOnHover={false} />Motion Behavior
- Scroll: Uses
requestAnimationFramefor smooth 60fps updates - Velocity smoothing: Exponential easing when pausing/resuming
- Tilt: Mouse position maps to ±15deg rotation on individual logos
- Wrapping: Position resets via modulo when reaching sequence width
Performance Notes
- ResizeObserver calculates required duplicates dynamically
- Uses CSS
transform: translate3dfor GPU acceleration - Velocity transitions use exponential smoothing (no React re-renders)
- Logos are duplicated in DOM only as needed
Examples
Image Logos
const brands = [
{ src: "/logo1.png", alt: "Brand 1" },
{ src: "/logo2.png", alt: "Brand 2" }
];
<LogoLoop items={brands} />Notes
- Component automatically calculates how many duplicates are needed
- Works with both image URLs and React components
- Gradient masks on edges for fade effect
- Default logos use Lucide icons