Skip to content
Brok UI

Loading…

No results

Logo Cloud

Open source

Trusted-by logo clouds — a bordered grid (with an optional cursor-spotlight reveal), a marquee, an inline-heading row and an animated group cycler.

Preview

4 of 5

resources/views/blocks/logo-cloud-cycle.blade.php Blade
@props([
    'heading' => 'Trusted by teams using',
    'logos' => null,
    'groupSize' => 4,
    'interval' => 2500,
])

@php
    $logos ??= [
        'Northwind',
        'Acme Corp',
        'Globex',
        'Initech',
        'Umbrella',
        'Soylent',
        'Hooli',
        'Vehement',
        'Stripe',
        'Vercel',
    ];

    // Translate up-front so Alpine renders already-localized labels.
    $logoLabels = array_values(array_map(fn ($logo) => __($logo), $logos));
@endphp

<section {{ $attributes->merge(['class' => 'bg-background py-16 text-foreground sm:py-20']) }}>
    <div
        class="mx-auto max-w-6xl px-4"
        x-data="uiLogoCloudCycle({
            logos: @js($logoLabels),
            groupSize: {{ (int) $groupSize }},
            interval: {{ (int) $interval }},
        })"
    >
        @if ($heading)
            <p class="text-center text-sm font-medium uppercase tracking-wider text-muted-foreground">
                {{ __($heading) }}
            </p>
            <x-ui.separator class="mx-auto mt-6 max-w-24" />
        @endif

        {{-- Static, screen-reader / SEO copy: every brand exists in the DOM regardless of which group is on stage. --}}
        <ul class="sr-only">
            @foreach ($logoLabels as $logo)
                <li>{{ $logo }}</li>
            @endforeach
        </ul>

        {{-- Animated stage: aria-hidden so the cycling chips don't spam assistive tech. --}}
        <div
            class="mt-10 flex min-h-24 items-center justify-center"
            aria-hidden="true"
            @mouseenter="pause()"
            @mouseleave="resume()"
        >
            <ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-4 sm:gap-x-10">
                <template x-for="(logo, i) in current" :key="index + '-' + i">
                    <li
                        class="inline-flex items-center justify-center rounded-md border border-border bg-card px-6 py-2 text-base font-semibold tracking-tight text-muted-foreground opacity-0 shadow-sm transition-[opacity,filter,color] duration-500 ease-out [filter:blur(4px)] motion-reduce:transition-none motion-reduce:opacity-100 motion-reduce:[filter:none] hover:text-foreground"
                        :class="shown ? 'opacity-100 [filter:blur(0)]' : 'opacity-0 [filter:blur(4px)]'"
                        :style="`transition-delay:${i * 80}ms`"
                        x-text="logo"
                    ></li>
                </template>
            </ul>
        </div>
    </div>
</section>
resources/js/ui/logo-cloud-cycle.js JS
/**
 * Cycling logo cloud behavior.
 *
 * Splits a flat list of (already-localized) brand labels into fixed-size groups
 * and rotates through them on a timer. Each transition fades + un-blurs the
 * incoming group in (per-item staggered delay handled in the Blade `:style`),
 * after briefly hiding the outgoing one. All labels also live in a static
 * `sr-only` list in the markup, so this animated stage is purely visual and is
 * marked `aria-hidden`.
 *
 * Under `prefers-reduced-motion: reduce` the cycle never starts (the Blade's
 * `motion-reduce:` variants only remove the fade — a 2.5 s content swap is
 * still motion). The rotation pauses while the pointer or keyboard focus is
 * inside the stage, so the content can be read — WCAG 2.2.2 (Pause, Stop,
 * Hide) for auto-updating content. Bind `@mouseenter="pause()"`,
 * `@mouseleave="resume()"`, `@focusin="pause()"`, `@focusout="resume()"` on
 * the stage.
 *
 * Self-registers on `alpine:init` so import order does not matter. The rotation
 * timer and any pending swap timeout are cleared in `destroy()` to avoid leaks
 * when the section unmounts mid-cycle.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiLogoCloudCycle', (config = {}) => ({
        logos: Array.isArray(config.logos) ? config.logos : [],
        groupSize: Math.max(1, parseInt(config.groupSize, 10) || 4),
        interval: Math.max(600, parseInt(config.interval, 10) || 2500),
        index: 0,
        shown: true,
        timer: null,
        swap: null,

        paused: false,

        init() {
            // Single-group lists have nothing to cycle through — show and stop.
            if (this.groups.length <= 1) return;

            // Re-read live, not once: an OS toggle mid-session must take effect.
            this._mq = window.matchMedia('(prefers-reduced-motion: reduce)');
            this._onMq = () => (this._mq.matches ? this.stop() : this.start());
            this._mq.addEventListener('change', this._onMq);
            this._onMq();
        },

        destroy() {
            this.stop();
            if (this.swap) clearTimeout(this.swap);
            if (this._mq && this._onMq) this._mq.removeEventListener('change', this._onMq);
        },

        start() {
            if (this.timer || this.paused || this.groups.length <= 1) return;
            this.timer = setInterval(() => this.advance(), this.interval);
        },

        stop() {
            if (this.timer) clearInterval(this.timer);
            this.timer = null;
        },

        pause() {
            this.paused = true;
            this.stop();
        },

        resume() {
            this.paused = false;
            if (!this._mq?.matches) this.start();
        },

        // Chunk the flat list into groups of `groupSize`.
        get groups() {
            const out = [];
            for (let i = 0; i < this.logos.length; i += this.groupSize) {
                out.push(this.logos.slice(i, i + this.groupSize));
            }
            return out.length ? out : [[]];
        },

        get current() {
            return this.groups[this.index] ?? [];
        },

        // Fade the current group out, swap to the next, then fade it back in.
        advance() {
            this.shown = false;
            if (this.swap) clearTimeout(this.swap);
            this.swap = setTimeout(() => {
                const len = this.groups.length;
                this.index = (this.index + 1) % len;
                this.shown = true;
            }, 350);
        },
    }));
});

Installation

terminal
php artisan ui:add blocks/logo-cloud-cycle

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/logo-cloud-cycle writes only the generated targets below. The CLI validates each file hash before writing and prompts before replacing local changes unless --force is used.

Version
1.1.1
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/logo-cloud-cycle.blade.php
js resources/js/ui/logo-cloud-cycle.js

Registry dependencies

separator

Package dependencies

composer: jml/brok:^0.2

npm: alpinejs

Use with AI

A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.

logo-cloud-cycle.md
# Brok UI block: Logo Cloud Cycle (`logo-cloud-cycle`)

An animated trusted-by logo cloud that cycles through groups of wordmark chips with a staggered blur-and-fade transition between groups.

Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.

## Install

```bash
php artisan ui:add blocks/logo-cloud-cycle
```

## Render it

```blade
<x-blocks.logo-cloud-cycle />
```

## Props

- `heading` (string, default `Trusted by teams using`) — Centered muted label above the logo cloud; falsy hides it and its separator.
- `logos` (mixed|null, default `null`) — Array of logo name strings; falls back to a 10-item sample list when null.
- `group-size` (int, default `4`) — Number of logo chips shown per cycling group.
- `interval` (int, default `2500`) — Milliseconds between automatic group rotations.

## Use when

- Use it for a 'trusted by' section that automatically cycles through groups of logo chips with a staggered blur-and-fade transition, fitting many brands into a small footprint.
- Reach for it when the interval-based rotation (configurable via groupSize/interval) and pause-on-hover behavior are wanted; every logo still exists in the DOM for SEO/screen readers.

## Avoid when

- Avoid it when all logos should be visible simultaneously without waiting for a rotation; use logo-cloud-grid or logo-cloud-cards instead.

## Rules

- Render the installed block with `<x-blocks.logo-cloud-cycle />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.

## Links

- Docs: https://brokui.dev/blocks/logo-cloud-cycle
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/logo-cloud-cycle.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Guidance

Use when

  • Use it for a 'trusted by' section that automatically cycles through groups of logo chips with a staggered blur-and-fade transition, fitting many brands into a small footprint.
  • Reach for it when the interval-based rotation (configurable via groupSize/interval) and pause-on-hover behavior are wanted; every logo still exists in the DOM for SEO/screen readers.

Avoid when

  • Avoid it when all logos should be visible simultaneously without waiting for a rotation; use logo-cloud-grid or logo-cloud-cards instead.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
centered muted heading separator screen-reader-only full logo list animated cycling stage logo chip group

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.logo-cloud-cycle />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

This block composes installed <brok:*> primitives and semantic design tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by editing CSS variables; flip the preview to RTL to confirm it mirrors.

Source

The exact, editable files ui:add writes into your app. The preview above renders this same source — there are no preview-only blocks.

resources/views/blocks/logo-cloud-cycle.blade.php Blade
@props([
    'heading' => 'Trusted by teams using',
    'logos' => null,
    'groupSize' => 4,
    'interval' => 2500,
])

@php
    $logos ??= [
        'Northwind',
        'Acme Corp',
        'Globex',
        'Initech',
        'Umbrella',
        'Soylent',
        'Hooli',
        'Vehement',
        'Stripe',
        'Vercel',
    ];

    // Translate up-front so Alpine renders already-localized labels.
    $logoLabels = array_values(array_map(fn ($logo) => __($logo), $logos));
@endphp

<section {{ $attributes->merge(['class' => 'bg-background py-16 text-foreground sm:py-20']) }}>
    <div
        class="mx-auto max-w-6xl px-4"
        x-data="uiLogoCloudCycle({
            logos: @js($logoLabels),
            groupSize: {{ (int) $groupSize }},
            interval: {{ (int) $interval }},
        })"
    >
        @if ($heading)
            <p class="text-center text-sm font-medium uppercase tracking-wider text-muted-foreground">
                {{ __($heading) }}
            </p>
            <x-ui.separator class="mx-auto mt-6 max-w-24" />
        @endif

        {{-- Static, screen-reader / SEO copy: every brand exists in the DOM regardless of which group is on stage. --}}
        <ul class="sr-only">
            @foreach ($logoLabels as $logo)
                <li>{{ $logo }}</li>
            @endforeach
        </ul>

        {{-- Animated stage: aria-hidden so the cycling chips don't spam assistive tech. --}}
        <div
            class="mt-10 flex min-h-24 items-center justify-center"
            aria-hidden="true"
            @mouseenter="pause()"
            @mouseleave="resume()"
        >
            <ul class="flex flex-wrap items-center justify-center gap-x-6 gap-y-4 sm:gap-x-10">
                <template x-for="(logo, i) in current" :key="index + '-' + i">
                    <li
                        class="inline-flex items-center justify-center rounded-md border border-border bg-card px-6 py-2 text-base font-semibold tracking-tight text-muted-foreground opacity-0 shadow-sm transition-[opacity,filter,color] duration-500 ease-out [filter:blur(4px)] motion-reduce:transition-none motion-reduce:opacity-100 motion-reduce:[filter:none] hover:text-foreground"
                        :class="shown ? 'opacity-100 [filter:blur(0)]' : 'opacity-0 [filter:blur(4px)]'"
                        :style="`transition-delay:${i * 80}ms`"
                        x-text="logo"
                    ></li>
                </template>
            </ul>
        </div>
    </div>
</section>
resources/js/ui/logo-cloud-cycle.js JS
/**
 * Cycling logo cloud behavior.
 *
 * Splits a flat list of (already-localized) brand labels into fixed-size groups
 * and rotates through them on a timer. Each transition fades + un-blurs the
 * incoming group in (per-item staggered delay handled in the Blade `:style`),
 * after briefly hiding the outgoing one. All labels also live in a static
 * `sr-only` list in the markup, so this animated stage is purely visual and is
 * marked `aria-hidden`.
 *
 * Under `prefers-reduced-motion: reduce` the cycle never starts (the Blade's
 * `motion-reduce:` variants only remove the fade — a 2.5 s content swap is
 * still motion). The rotation pauses while the pointer or keyboard focus is
 * inside the stage, so the content can be read — WCAG 2.2.2 (Pause, Stop,
 * Hide) for auto-updating content. Bind `@mouseenter="pause()"`,
 * `@mouseleave="resume()"`, `@focusin="pause()"`, `@focusout="resume()"` on
 * the stage.
 *
 * Self-registers on `alpine:init` so import order does not matter. The rotation
 * timer and any pending swap timeout are cleared in `destroy()` to avoid leaks
 * when the section unmounts mid-cycle.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiLogoCloudCycle', (config = {}) => ({
        logos: Array.isArray(config.logos) ? config.logos : [],
        groupSize: Math.max(1, parseInt(config.groupSize, 10) || 4),
        interval: Math.max(600, parseInt(config.interval, 10) || 2500),
        index: 0,
        shown: true,
        timer: null,
        swap: null,

        paused: false,

        init() {
            // Single-group lists have nothing to cycle through — show and stop.
            if (this.groups.length <= 1) return;

            // Re-read live, not once: an OS toggle mid-session must take effect.
            this._mq = window.matchMedia('(prefers-reduced-motion: reduce)');
            this._onMq = () => (this._mq.matches ? this.stop() : this.start());
            this._mq.addEventListener('change', this._onMq);
            this._onMq();
        },

        destroy() {
            this.stop();
            if (this.swap) clearTimeout(this.swap);
            if (this._mq && this._onMq) this._mq.removeEventListener('change', this._onMq);
        },

        start() {
            if (this.timer || this.paused || this.groups.length <= 1) return;
            this.timer = setInterval(() => this.advance(), this.interval);
        },

        stop() {
            if (this.timer) clearInterval(this.timer);
            this.timer = null;
        },

        pause() {
            this.paused = true;
            this.stop();
        },

        resume() {
            this.paused = false;
            if (!this._mq?.matches) this.start();
        },

        // Chunk the flat list into groups of `groupSize`.
        get groups() {
            const out = [];
            for (let i = 0; i < this.logos.length; i += this.groupSize) {
                out.push(this.logos.slice(i, i + this.groupSize));
            }
            return out.length ? out : [[]];
        },

        get current() {
            return this.groups[this.index] ?? [];
        },

        // Fade the current group out, swap to the next, then fade it back in.
        advance() {
            this.shown = false;
            if (this.swap) clearTimeout(this.swap);
            this.swap = setTimeout(() => {
                const len = this.groups.length;
                this.index = (this.index + 1) % len;
                this.shown = true;
            }, 350);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.1
Status
Stable
License
open
Deprecation
Not deprecated
Contract
v1
Foundation
≥ 1.0.0