Marquee
An infinite, seamless scrolling row or column; the scroll-velocity banner is a mode of the same composition.
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
<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
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:
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.
-
resources/views/components/ui/marquee.blade.php -
resources/js/ui/marquee.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: 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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
@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
/**
* 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