Logo Cloud Marquee
An auto-scrolling marquee of editable logos with progressive-blur edge fades, installed as a wired Payload block.
import type { Block } from 'payload'import { logoCloudFields } from '@/blocks/shared/logoCloudFields'export const LogoCloudMarquee: Block = { slug: 'logoCloudMarquee', interfaceName: 'LogoCloudMarqueeBlock', fields: [ // Shared logo-cloud core (heading + logos). Edit the shared shape in // @/blocks/shared/logoCloudFields to update every logo-cloud variant. ...logoCloudFields, ], labels: { plural: 'Logo Cloud Marquee Blocks', singular: 'Logo Cloud Marquee', },}import React from 'react'import type { LogoCloudMarqueeBlock as LogoCloudMarqueeBlockData } from '@/payload-types'import { Media } from '@/components/Media'import { InfiniteSlider } from '@/components/ui/infinite-slider'import { ProgressiveBlur } from '@/components/ui/progressive-blur'import { cn } from '@/utilities/ui'type Props = LogoCloudMarqueeBlockData & { id?: string className?: string disableInnerContainer?: boolean}export const LogoCloudMarqueeBlock: React.FC<Props> = ({ className, disableInnerContainer, heading, id, logos,}) => { return ( <section className={cn('container', className)} id={id ? `block-${id}` : undefined}> <div className="overflow-hidden rounded-frame border border-border/70 bg-card/35 px-6 py-10 sm:px-8 lg:px-12 lg:py-14"> <div className={cn('flex flex-col items-center gap-6 md:flex-row md:gap-0', { 'mx-auto max-w-6xl': !disableInnerContainer, })} > <div className="md:max-w-44 md:border-r md:border-border/70 md:pr-6"> <p className="text-center text-sm text-muted-foreground md:text-end">{heading}</p> </div> <div className="relative w-full py-6 md:w-[calc(100%-11rem)]"> <InfiniteSlider gap={112} speed={40} speedOnHover={20}> {logos?.map((item, index) => { const logo = <Media resource={item.logo} imgClassName="h-7 w-auto object-contain" /> return ( <div className="flex items-center justify-center" key={item.id ?? `${item.name}-${index}`} > {item.href ? <a href={item.href}>{logo}</a> : logo} </div> ) })} </InfiniteSlider> <div aria-hidden className="pointer-events-none absolute inset-y-0 left-0 w-20 bg-gradient-to-r from-card/80 to-transparent" /> <div aria-hidden className="pointer-events-none absolute inset-y-0 right-0 w-20 bg-gradient-to-l from-card/80 to-transparent" /> <ProgressiveBlur blurIntensity={1} className="pointer-events-none absolute left-0 top-0 h-full w-20" direction="left" /> <ProgressiveBlur blurIntensity={1} className="pointer-events-none absolute right-0 top-0 h-full w-20" direction="right" /> </div> </div> </div> </section> )}import type { Field } from 'payload'/** * Shared field core for the Logo Cloud kit family. * * Every logo-cloud variant (logo-cloud-grid, logo-cloud-hover, * logo-cloud-marquee, logo-cloud-inline, logo-cloud-inline-wrap, …) spreads * these fields first and then appends its own variant-specific shape (for * example the hover variant adds a CTA link group). Editing the shared * heading/logos shape here updates every installed logo-cloud block at once, * so the family never drifts field-by-field across a repo. * * Each logo is an editable Media upload plus an accessible name and an * optional link, so editors manage the wall of logos from the admin instead * of shipping hardcoded brand SVGs. * * Installed once per repo at `src/blocks/shared/logoCloudFields.ts`; re-running * `payload-components add logo-cloud-*` never overwrites a copy you have already edited. */export const logoCloudFields: Field[] = [ { name: 'heading', type: 'text', required: true, }, { name: 'logos', type: 'array', required: true, minRows: 2, maxRows: 12, admin: { initCollapsed: true, }, fields: [ { name: 'logo', type: 'upload', relationTo: 'media', required: true, }, { name: 'name', type: 'text', required: true, }, { name: 'href', type: 'text', }, ], },]'use client'import type { ReactNode } from 'react'import { animate, motion, useMotionValue, useReducedMotion } from 'motion/react'import { useEffect, useRef, useState } from 'react'import { cn } from '@/utilities/ui'/** * Continuously scrolling row, ported from the motion-primitives InfiniteSlider * (MIT) into the payload-components family. Self-contained: the only runtime * dependency is `motion`; element width is measured with a local * ResizeObserver instead of an extra package. * * Used by the Logo Cloud Marquee block to scroll an editable wall of logos. */function useElementWidth() { const ref = useRef<HTMLDivElement | null>(null) const [width, setWidth] = useState(0) useEffect(() => { const element = ref.current if (!element) return const observer = new ResizeObserver((entries) => { const entry = entries[0] if (entry) setWidth(entry.contentRect.width) }) observer.observe(element) return () => observer.disconnect() }, []) return [ref, width] as const}export type InfiniteSliderProps = { children: ReactNode className?: string gap?: number reverse?: boolean speed?: number speedOnHover?: number}export function InfiniteSlider({ children, className, gap = 16, reverse = false, speed = 100, speedOnHover,}: InfiniteSliderProps) { const [currentSpeed, setCurrentSpeed] = useState(speed) const [ref, width] = useElementWidth() const translation = useMotionValue(0) const [isTransitioning, setIsTransitioning] = useState(false) const [key, setKey] = useState(0) const shouldReduceMotion = useReducedMotion() useEffect(() => { // Respect the user's reduced-motion preference: skip the infinite scroll // and leave the row static (WCAG 2.2.2 Pause/Stop/Hide, 2.3.3). if (shouldReduceMotion) return const contentSize = width + gap const from = reverse ? -contentSize / 2 : 0 const to = reverse ? 0 : -contentSize / 2 const controls = isTransitioning ? animate(translation, [translation.get(), to], { duration: Math.abs((translation.get() - to) / currentSpeed), ease: 'linear', onComplete: () => { setIsTransitioning(false) setKey((prev) => prev + 1) }, }) : animate(translation, [from, to], { duration: contentSize / currentSpeed, ease: 'linear', onRepeat: () => { translation.set(from) }, repeat: Infinity, repeatDelay: 0, repeatType: 'loop', }) return controls?.stop }, [key, translation, currentSpeed, width, gap, isTransitioning, reverse, shouldReduceMotion]) const hoverProps = speedOnHover && !shouldReduceMotion ? { onHoverEnd: () => { setIsTransitioning(true) setCurrentSpeed(speed) }, onHoverStart: () => { setIsTransitioning(true) setCurrentSpeed(speedOnHover) }, } : {} return ( <div className={cn('overflow-hidden', className)}> <motion.div className="flex w-max" ref={ref} style={{ gap: `${gap}px`, x: translation }} {...hoverProps} > {children} {children} </motion.div> </div> )}'use client'import type { HTMLMotionProps } from 'motion/react'import { motion } from 'motion/react'import { cn } from '@/utilities/ui'/** * Layered directional blur, ported from the motion-primitives ProgressiveBlur * (MIT) into the payload-components family. Only runtime dependency is `motion`. * * Used by the Logo Cloud Marquee block to fade the scrolling logos into the * card edges. */export const GRADIENT_ANGLES = { bottom: 180, left: 270, right: 90, top: 0,}export type ProgressiveBlurProps = { blurIntensity?: number blurLayers?: number className?: string direction?: keyof typeof GRADIENT_ANGLES} & HTMLMotionProps<'div'>export function ProgressiveBlur({ blurIntensity = 0.25, blurLayers = 8, className, direction = 'bottom', ...props}: ProgressiveBlurProps) { const layers = Math.max(blurLayers, 2) const segmentSize = 1 / (blurLayers + 1) return ( <div className={cn('relative', className)}> {Array.from({ length: layers }).map((_, index) => { const angle = GRADIENT_ANGLES[direction] const gradientStops = [ index * segmentSize, (index + 1) * segmentSize, (index + 2) * segmentSize, (index + 3) * segmentSize, ].map( (pos, posIndex) => `rgba(255, 255, 255, ${posIndex === 1 || posIndex === 2 ? 1 : 0}) ${pos * 100}%`, ) const gradient = `linear-gradient(${angle}deg, ${gradientStops.join(', ')})` return ( <motion.div className="absolute inset-0 rounded-[inherit]" key={index} style={{ WebkitMaskImage: gradient, backdropFilter: `blur(${index * blurIntensity}px)`, maskImage: gradient, }} {...props} /> ) })} </div> )}Installation
npx payload-components add logo-cloud-marqueeCopy the files straight from the registry, then wire the Payload fragments by hand:
pnpm dlx shadcn@latest add https://www.payload-components.xyz/r/logo-cloud-marquee.jsonWhat it installs
Copies 5 source files into your project:
src/blocks/shared/logoCloudFields.tssharedsrc/components/ui/infinite-slider.tsxsrc/components/ui/progressive-blur.tsxsrc/blocks/LogoCloudMarquee/config.tssrc/blocks/LogoCloudMarquee/Component.tsx
…and makes 4 edits to wire the block into your project:
| Registers the block | src/collections/Pages/index.ts |
| Maps the renderer | src/blocks/RenderBlocks.tsx |
| Regenerates types | src/payload-types.ts |
| Regenerates the admin import map | src/app/(payload)/admin/importMap.js |
logoCloudFields.ts is the shared field core for this family — every variant composes it. Editing it updates each installed block at once, and re-running an install never overwrites a copy you have changed.Re-running the install converges: it detects existing wiring, skips it, and records install state in .payload-components/state.json.
This variant also installs the shared InfiniteSlider and ProgressiveBlur client components and
adds the motion package — the marquee animation runs on the client.
Content model
Both fields come from the shared logoCloudFields base shared across the Logo Cloud family.
Prop
Type
Each item in logos carries:
Prop
Type
Usage
LogoCloudMarquee block to its layout.RenderBlocks on the frontend, fully typed — no extra wiring.Requirements
- Target
- payload-website-starter
- Payload
- v3
- Next.js
- 15 / 16
- shadcn UI
- none
Your project must already expose components.json, src/payload.config.ts, src/blocks/RenderBlocks.tsx, src/collections/Pages/index.ts — the surfaces payload-components add patches. The CLI verifies this against the support matrix before touching anything.
In this family
logo-cloud-gridlogo-cloud-hoverlogo-cloud-marqueecurrentlogo-cloud-inlinelogo-cloud-inline-wrap