Skip to content
Brok UI

Loading…

No results

Marquee

Open source

An infinite, seamless scrolling row or column; the scroll-velocity banner is a mode of the same composition.

Version
v1.2.4
Stability
stable
License
MIT
Related
Carousel
Cluster
Stack

Marquee — An infinite, seamless scrolling row or column with horizontal/vertical direction, pause-on-hover, edge fade and grayscale options. `velocity` switches to a JS-driven loop that accelerates with the page's scroll velocity (the velocity-scroll shape).

Preview

Laravel Tailwind CSS Alpine.js Blade Livewire Vite
previews.components.marquee.default.blade.php Blade
<x-ui.marquee class="max-w-xl">
    @foreach (['Laravel', 'Tailwind CSS', 'Alpine.js', 'Blade', 'Livewire', 'Vite'] as $name)
        <x-ui.badge variant="secondary">{{ $name }}</x-ui.badge>
    @endforeach
</x-ui.marquee>

Installation

terminal
php artisan ui:add marquee

Note

This component ships an Alpine behavior module at resources/js/ui/marquee.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './marquee.js';

Registry contract

php artisan ui:add marquee writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/marquee.blade.php
  • js resources/js/ui/marquee.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

marquee.md
# Brok UI: Marquee (`marquee`)

An infinite, seamless scrolling row or column with horizontal/vertical direction, pause-on-hover, edge fade and grayscale options. `velocity` switches to a JS-driven loop that accelerates with the page's scroll velocity (the velocity-scroll shape).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add marquee
```

## Usage

```blade
<x-ui.marquee class="max-w-xl">
    @foreach (['Laravel', 'Tailwind CSS', 'Alpine.js', 'Blade', 'Livewire', 'Vite'] as $name)
        <x-ui.badge variant="secondary">{{ $name }}</x-ui.badge>
    @endforeach
</x-ui.marquee>
```

## Props

- `direction` (string, default `left`) — Scroll direction: left/right (horizontal, both modes) or up/down (vertical, default mode only).
- `pauseOnHover` (bool, default `true`) — Pauses the scroll while the pointer is over the marquee (default mode only).
- `fade` (bool, default `true`) — Adds an edge mask so content fades in and out at the container edges (default mode only).
- `grayscale` (bool, default `false`) — Renders the content in grayscale (default mode only).
- `duration` (int, default `40`) — Seconds for one full loop (default mode only).
- `gap` (string, default `1rem`) — CSS gap between repeated copies (default mode only).
- `repeat` (int, default `2`) — Number of identical copies rendered to fill the container; minimum 2 for a seamless loop (default mode only).
- `velocity` (bool, default `false`) — Switches to a JS-driven loop whose speed accelerates with the page's scroll velocity, instead of the constant-speed CSS keyframe loop.
- `baseVelocity` (int, default `4`) — Baseline scroll speed used only when velocity is true.
- `text` (string, default ``) — Text content used only when velocity is true and no slot content is supplied.

## Use when

- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Scrolling a row or column of content (logos, text, cards) infinitely and seamlessly, with pause-on-hover and edge fade.
- Wanting a scroll-velocity-driven loop that speeds up and flips direction with the page's scroll (velocity=true, the velocity-scroll shape).

## Avoid when

- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Needing a fixed, non-scrolling row of items; use cluster or stack instead of the seamless-loop complexity.
- Needing vertical scroll with velocity coupling; velocity mode is horizontal only (left/right).

## Anti-patterns

- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus

## Rules

- Use the `<brok:marquee>` tag (or `<x-ui.marquee>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/marquee
- Registry JSON (files, props, contract): https://brokui.dev/r/open/marquee.json

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

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
direction string left Scroll direction: left/right (horizontal, both modes) or up/down (vertical, default mode only).
pauseOnHover bool true Pauses the scroll while the pointer is over the marquee (default mode only).
fade bool true Adds an edge mask so content fades in and out at the container edges (default mode only).
grayscale bool false Renders the content in grayscale (default mode only).
duration int 40 Seconds for one full loop (default mode only).
gap string 1rem CSS gap between repeated copies (default mode only).
repeat int 2 Number of identical copies rendered to fill the container; minimum 2 for a seamless loop (default mode only).
velocity bool false Switches to a JS-driven loop whose speed accelerates with the page's scroll velocity, instead of the constant-speed CSS keyframe loop.
baseVelocity int 4 Baseline scroll speed used only when velocity is true.
text string Text content used only when velocity is true and no slot content is supplied.

Slots

  • default — Repeated content in the default mode; the visible copy's source in velocity mode when no text prop is supplied.

Data slots

Stable hooks for CSS overrides and browser tests.

marquee-copy marquee-track velocity-scroll {{ $slotName }}

Behavior

  • In the default mode, repeat identical copies scroll together via a CSS keyframe; the first copy carries the accessible content and the rest are aria-hidden duplicates.
  • In velocity mode, marquee.js translates two copies each frame at a speed derived from the page's scroll velocity and can flip direction with scroll direction; reduced motion renders the text static and fully readable in both modes.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Expressive and showcase effect

Support brand expression or a showcase moment.

Use when

  • Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
  • Scrolling a row or column of content (logos, text, cards) infinitely and seamlessly, with pause-on-hover and edge fade.
  • Wanting a scroll-velocity-driven loop that speeds up and flips direction with the page's scroll (velocity=true, the velocity-scroll shape).

Avoid when

  • Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
  • Needing a fixed, non-scrolling row of items; use cluster or stack instead of the seamless-loop complexity.
  • Needing vertical scroll with velocity coupling; velocity mode is horizontal only (left/right).

Use instead

  • Static content-first presentation

Anti-patterns

  • Heavy effects in forms or task-dense workflows
  • Motion that interferes with reading or focus
Anatomy
root velocity-scroll marquee-track marquee-copy
Theming hooks
marquee-copy marquee-track velocity-scroll {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • Velocity mode carries role="marquee" with an aria-label of the plain text, so the looping duplicate copies stay aria-hidden and the content is announced once.
  • The default mode's motion is motion-safe only; reduced-motion users get a static, fully visible and scrollable row or column instead of the looping animation.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <x-ui.marquee class="max-w-xl">
        @foreach (['Laravel', 'Tailwind CSS', 'Alpine.js', 'Blade', 'Livewire', 'Vite'] as $name)
            <x-ui.badge variant="secondary">{{ $name }}</x-ui.badge>
        @endforeach
    </x-ui.marquee>
</div>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/marquee.blade.php Blade
@props([
    'direction' => 'left',
    'pauseOnHover' => true,
    'fade' => true,
    'grayscale' => false,
    'duration' => 40,
    'gap' => '1rem',
    'repeat' => 2,
    // false (default): a constant-speed CSS keyframe loop (any slot content,
    // horizontal or vertical, any `repeat` count). true: a JS-driven loop
    // whose speed accelerates with the page's scroll velocity and flips
    // direction with scroll direction — the velocity-scroll shape. Horizontal
    // only (left/right), exactly two copies, and reads `text`/`baseVelocity`
    // instead of `duration`/`gap`/`repeat`/`grayscale`.
    'velocity' => false,
    'baseVelocity' => 4,
    'text' => '',
])

@php
    $velocity = filter_var($velocity, FILTER_VALIDATE_BOOLEAN);

    // velocity-scroll composes this as <x-ui.marquee velocity
    // data-slot="velocity-scroll">; the browser keeps only the first of two
    // identical attributes, so read the override and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'marquee');
@endphp

@if ($velocity)
    @php
        // Faithful reimplementation of componentry.fun "Scroll Based Velocity":
        // a seamless two-copy track is translated horizontally each rAF frame at
        // a baseline speed, then sped up by the page's scroll velocity (and its
        // sign can flip the travel direction). Behavior lives in uiMarquee
        // (resources/js/ui/marquee.js), only loaded when `velocity` is true. The
        // visible copy carries real, readable text; the second copy is
        // aria-hidden and exists only to make the loop seamless. Reduced-motion:
        // no rAF / no velocity coupling — the text renders static and fully
        // readable (see motion-safe gating below + the JS guard).
        $velocityDirection = in_array($direction, ['right', 'down'], true) ? 'right' : 'left';
        $content = trim((string) $slot) !== '' ? $slot : $text;
        $fromText = trim((string) $slot) === '';
    @endphp
    <div
        data-slot="{{ $slotName }}"
        data-direction="{{ $velocityDirection }}"
        role="marquee"
        aria-label="{{ trim(strip_tags((string) ($text !== '' ? $text : $content))) }}"
        x-data="uiMarquee({ baseVelocity: {{ (float) $baseVelocity }}, direction: @js($velocityDirection) })"
        x-ref="root"
        {{ $attributes->except('data-slot')->merge(['class' => 'relative flex w-full overflow-hidden whitespace-nowrap']) }}
    >
        {{-- Track: two identical copies side by side so translateX can wrap seamlessly. --}}
        <div
            data-slot="marquee-track"
            x-ref="track"
            class="flex shrink-0 flex-row flex-nowrap motion-reduce:!transform-none"
        >
            {{-- For `text`, whitespace-pre keeps a trailing separator space
                 ("Scroll to accelerate, ") that nowrap drops at each copy's end;
                 slot markup keeps normal whitespace handling.
                 Both copies are aria-hidden: the root is a labelled role="marquee",
                 so the text is announced once from its aria-label rather than
                 twice from the seamless loop. --}}
            <span
                data-slot="marquee-copy"
                x-ref="copy"
                aria-hidden="true"
                @class(['block shrink-0', 'whitespace-pre' => $fromText])
            >{{ $content }}</span>
            <span
                data-slot="marquee-copy"
                aria-hidden="true"
                @class(['block shrink-0', 'whitespace-pre' => $fromText])
            >{{ $content }}</span>
        </div>
    </div>
@else
    @php
        // `repeat` identical halves scroll in lockstep (keyframes in ui.css). The whole
        // thing is motion-safe only, so reduced-motion users get a static, fully
        // visible (and scrollable) row/column.
        //
        // direction: left|right scroll horizontally; up|down scroll vertically.
        // Vertical marquees need an explicit height from the consumer (e.g. h-64).
        $vertical = in_array($direction, ['up', 'down'], true);
        $reverse = in_array($direction, ['right', 'down'], true) ? '[animation-direction:reverse]' : '';
        $pause = $pauseOnHover ? 'group-hover:[animation-play-state:paused]' : '';
        $gray = $grayscale ? 'grayscale' : '';

        // The animation utility must be written as a contiguous literal per axis:
        // Tailwind's JIT only generates an arbitrary-value class it can see verbatim
        // in source. Concatenating the keyframe name in (…animate-['.$anim.'…])
        // hid the class from the scanner, so the utility was never emitted and the
        // marquee sat static.
        $animClass = $vertical
            ? 'motion-safe:animate-[ui-marquee-y_var(--duration)_linear_infinite]'
            : 'motion-safe:animate-[ui-marquee-x_var(--duration)_linear_infinite]';
        $axisFlow = $vertical ? 'flex-col' : 'flex-row';

        $half = 'flex '.$axisFlow.' shrink-0 items-center justify-around gap-[var(--gap)] '
            .$animClass.' '.$reverse.' '.$pause;

        // Edge fade via a mask along the scroll axis; transparent → opaque → transparent.
        $fadeAxis = $vertical ? 'to bottom' : 'to right';
        $fadeStyle = $fade
            ? 'mask-image:linear-gradient('.$fadeAxis.',transparent,black 12%,black 88%,transparent);'
                .'-webkit-mask-image:linear-gradient('.$fadeAxis.',transparent,black 12%,black 88%,transparent);'
            : '';

        // Horizontal fills its width; vertical fills its (consumer-given) height.
        // The keyframes move each half by 100% plus --gap, so the halves must be
        // --gap apart or they touch at the seam and the loop jumps by one gap.
        $rootFlow = $vertical ? 'flex-col h-full gap-[var(--gap)]' : 'flex-row w-full gap-[var(--gap)]';

        // At least two halves are required for the seamless loop; `repeat` adds more
        // identical copies to fill very wide/tall containers. The first half carries
        // the accessible content; the rest are aria-hidden duplicates.
        $copies = max(2, (int) $repeat);
    @endphp

    <div
        data-slot="{{ $slotName }}"
        data-direction="{{ $direction }}"
        style="--duration:{{ is_numeric($duration) ? $duration.'s' : $duration }}; --gap:{{ $gap }}; {{ $fadeStyle }}"
        {{ $attributes->except('data-slot')->merge(['class' => 'group relative flex '.$rootFlow.' overflow-hidden '.$gray]) }}
    >
        @for ($i = 0; $i < $copies; $i++)
            <div class="{{ $half }}" @if ($i > 0) aria-hidden="true" @endif>{{ $slot }}</div>
        @endfor
    </div>
@endif
resources/js/ui/marquee.js JS
/**
 * Marquee `velocity` mode behavior (faithful reimplementation of
 * componentry.fun "Scroll Based Velocity", previously velocity-scroll).
 *
 * Only wired up (`x-data="uiMarquee(...)"`) when `:velocity="true"` — the
 * default marquee is a pure CSS keyframe loop and never loads this module.
 *
 * A seamless track (two copies, plus clones until it covers the band) is translated horizontally each rAF frame. The
 * per-frame delta is `baseVelocity` scaled by a `velocityFactor` derived from
 * the window's scroll velocity: scrolling accelerates the marquee and the sign
 * of the scroll delta can flip the travel direction. When the user stops
 * scrolling the factor decays back to 1 so the track drifts at the slow
 * baseline. translateX is wrapped against one copy's width so the loop is
 * seamless.
 *
 * Reduced-motion: no rAF, no scroll listener — the text is left static and
 * fully readable (the band still shows the real content). The Blade view also
 * neutralises any transform via `motion-reduce:!transform-none`.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiMarquee', (config = {}) => ({
        baseVelocity: Number(config.baseVelocity ?? 4) || 4,
        // Baseline travel: -1 = leftward, +1 = rightward.
        baseDir: config.direction === 'right' ? 1 : -1,

        _x: 0,
        _width: 0,
        _frame: null,
        _lastTime: 0,
        _lastScrollY: 0,
        _velocityFactor: 1,
        _onScroll: null,
        _onResize: null,
        _reduce: null,

        init() {
            this._reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

            // Reduced motion → render static, fully readable text. No loop.
            if (this._reduce.matches) {
                return;
            }

            this.measure();

            this._lastScrollY = window.scrollY ?? 0;
            this._lastTime = performance.now();

            this._onScroll = () => {
                const y = window.scrollY ?? 0;
                const delta = y - this._lastScrollY;
                this._lastScrollY = y;
                // Map scroll delta to a factor: idle ≈ 1, scrolling amplifies and
                // its sign flips direction. Clamp so a flick can't overshoot wildly.
                const f = 1 + delta * 0.5;
                this._velocityFactor = Math.max(-8, Math.min(8, f));
            };
            this._onResize = () => this.measure();

            window.addEventListener('scroll', this._onScroll, { passive: true });
            window.addEventListener('resize', this._onResize, { passive: true });

            this._frame = requestAnimationFrame((t) => this.loop(t));
        },

        measure() {
            const copy = this.$refs.copy;
            this._width = copy ? copy.offsetWidth : 0;
            this.fill();
        },

        // The loop wraps after one copy, so the track must cover the container
        // plus one copy or a short `text` leaves the rest of the band empty.
        // Extra copies clone the aria-hidden second copy and are rebuilt on resize.
        fill() {
            const track = this.$refs.track;
            if (!track || this._width <= 0) return;
            track.querySelectorAll('[data-marquee-fill]').forEach((node) => node.remove());
            const copies = track.querySelectorAll('[data-slot="marquee-copy"]');
            const template = copies[copies.length - 1];
            const needed = Math.ceil(this.$el.clientWidth / this._width) + 1;
            for (let count = copies.length; count < needed; count++) {
                const clone = template.cloneNode(true);
                clone.removeAttribute('x-ref');
                clone.setAttribute('data-marquee-fill', '');
                track.appendChild(clone);
            }
        },

        loop(time) {
            const dt = Math.min(64, time - this._lastTime) / 16.6667; // ~frames elapsed
            this._lastTime = time;

            // Per-frame travel: baseline direction × speed × velocity factor.
            let move = this.baseDir * this.baseVelocity * this._velocityFactor * dt;
            this._x += move;

            // Wrap against one copy's width so the second (aria-hidden) copy fills
            // the seam and the loop is visually seamless in either direction.
            if (this._width > 0) {
                while (this._x <= -this._width) this._x += this._width;
                while (this._x > 0) this._x -= this._width;
            }

            const track = this.$refs.track;
            if (track) {
                track.style.transform = `translateX(${this._x}px)`;
            }

            // Decay the factor back toward the idle baseline (1) so it drifts.
            this._velocityFactor += (1 - this._velocityFactor) * Math.min(1, 0.05 * dt);

            this._frame = requestAnimationFrame((t) => this.loop(t));
        },

        destroy() {
            if (this._frame) cancelAnimationFrame(this._frame);
            if (this._onScroll) window.removeEventListener('scroll', this._onScroll);
            if (this._onResize) window.removeEventListener('resize', this._onResize);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.4
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0