Image Effects
Cursor- and scroll-driven image effects — trails, an infinite field and ripple refraction.
Preview
<x-ui.infinite-image-field class="h-80 w-full max-w-2xl rounded-lg border border-border" />
Installation
php artisan ui:add infinite-image-field
Note
This component ships an Alpine behavior module at
resources/js/ui/infinite-image-field.js. Import it once from your bundle so it registers on alpine:init:
import './infinite-image-field.js';
Registry contract
php artisan ui:add infinite-image-field
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/infinite-image-field.blade.php -
resources/js/ui/infinite-image-field.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: Infinite Image Field (`infinite-image-field`)
An endless, cursor-driven photo canvas where images tile infinitely; moving the pointer toward the edges accelerates panning in that direction with smooth momentum.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add infinite-image-field
```
## Usage
```blade
<x-ui.infinite-image-field class="h-80 w-full max-w-2xl rounded-lg border border-border" />
```
## Props
- `images` (array, default `[]`) — Array of image URLs tiled across the field; empty by default, which falls back to gradient tiles.
- `imageWidth` (int, default `160`) — Pixel width of each tile.
- `imageHeight` (int, default `200`) — Pixel height of each tile.
- `gap` (int, default `16`) — Pixel gap between tiles.
- `maxSpeed` (int, default `12`) — Maximum drift speed of the field.
- `smoothing` (float, default `0.08`) — Easing factor applied to speed changes; higher values settle faster.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Building an ambient, endlessly scrolling wall of images or gradient tiles for a hero or background section.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Images need to stay clickable, in a fixed order, or individually accessible; use a normal image grid instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:infinite-image-field>` tag (or `<x-ui.infinite-image-field>`) 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/infinite-image-field
- Registry JSON (files, props, contract): https://brokui.dev/r/open/infinite-image-field.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 |
|---|---|---|---|
| images | array | [] | Array of image URLs tiled across the field; empty by default, which falls back to gradient tiles. |
| imageWidth | int | 160 | Pixel width of each tile. |
| imageHeight | int | 200 | Pixel height of each tile. |
| gap | int | 16 | Pixel gap between tiles. |
| maxSpeed | int | 12 | Maximum drift speed of the field. |
| smoothing | float | 0.08 | Easing factor applied to speed changes; higher values settle faster. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders an over-sized tile grid inside an overflow-hidden viewport and translates it continuously, wrapping each tile modulo its size plus gap so the motion loops seamlessly at the edges.
- With no images, each tile falls back to a token gradient cycled by index, so the field ships no binary assets.
- 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.
- Building an ambient, endlessly scrolling wall of images or gradient tiles for a hero or background section.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- Images need to stay clickable, in a fixed order, or individually accessible; use a normal image grid instead.
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
- The tile grid is aria-hidden and every fallback img carries an empty alt, since the field is purely decorative background motion.
- 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.infinite-image-field class="h-80 w-full max-w-2xl rounded-lg border border-border" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'images' => [],
'imageWidth' => 160,
'imageHeight' => 200,
'gap' => 16,
'maxSpeed' => 12,
'smoothing' => 0.08,
])
@php
// The field is a `relative overflow-hidden` viewport. Inside it a grid of
// tiles is rendered large enough to cover the viewport plus one tile of
// margin on every side, so the wrap-around (modulo) repositioning done in JS
// is never visible at the edges. Tile cells and gap are fixed pixel sizes so
// the JS can compute the wrap distance (tile + gap) deterministically.
$tileW = (int) $imageWidth;
$tileH = (int) $imageHeight;
$gapPx = (int) $gap;
// Token-only gradient combos for the self-contained, themeable default field.
// No binary assets: each tile is a div with a `bg-gradient-to-br` using
// semantic tokens, varied by index so the field reads as a varied photo wall.
$gradients = [
'from-primary to-accent',
'from-secondary to-muted',
'from-accent to-primary',
'from-muted to-secondary',
'from-primary to-secondary',
'from-accent to-muted',
'from-secondary to-primary',
'from-muted to-accent',
];
$hasImages = ! empty($images);
// Enough tiles to over-cover a generous viewport; JS only ever moves these
// existing tiles (it never creates more), so the count is a fixed budget.
$columns = 8;
$rows = 6;
$count = $hasImages ? count($images) : $columns * $rows;
$config = [
'tileW' => $tileW,
'tileH' => $tileH,
'gap' => $gapPx,
'columns' => $columns,
'rows' => $rows,
'maxSpeed' => (float) $maxSpeed,
'smoothing' => (float) $smoothing,
];
@endphp
<div
x-data="uiInfiniteImageField(@js($config))"
data-slot="infinite-image-field"
{{ $attributes->merge(['class' => 'relative overflow-hidden bg-muted']) }}
>
{{-- The decorative tile grid. JS positions it (translate) and wraps each tile
modulo (tile+gap). It is purely ornamental, so it is hidden from AT. --}}
<div
x-ref="field"
{{-- inset-0 (not left/top only): the layer must have the field's size, or the
base `:where([data-slot]) { max-inline-size: 100% }` caps every tile at 0. --}}
class="pointer-events-none absolute inset-0 select-none will-change-transform"
aria-hidden="true"
>
@for ($i = 0; $i < $count; $i++)
<div
data-slot="infinite-image-field-tile"
class="absolute overflow-hidden rounded-md"
style="width: {{ $tileW }}px; height: {{ $tileH }}px;"
>
@if ($hasImages)
<img
src="{{ $images[$i % count($images)] }}"
alt=""
loading="lazy"
decoding="async"
draggable="false"
class="pointer-events-none size-full select-none object-cover"
/>
@else
<div class="size-full bg-gradient-to-br {{ $gradients[$i % count($gradients)] }}"></div>
@endif
</div>
@endfor
</div>
</div>
/**
* Infinite Image Field behavior (spec §19 behavior layer).
*
* An endless, cursor-driven photo canvas. A grid of fixed-size tiles tiles the
* viewport plus one tile of margin on each side. Every animation frame the
* pointer position relative to the viewport centre sets a *target* velocity:
* the further the pointer is toward an edge, the faster the field pans in that
* direction (clamped to `maxSpeed`). The current velocity lerps toward the
* target by `smoothing`, giving momentum, and is added to a running offset.
*
* The field appears infinite because each tile's drawn position is the offset
* folded with modulo over the tiled area (cols×tileStride, rows×tileStride):
* as a tile scrolls off one edge it wraps to the opposite edge. The field is
* positioned with a single inline `transform: translate3d(...)` set in JS; the
* per-tile base positions are written once on init.
*
* Movement is gated behind `prefers-reduced-motion`: when reduced motion is
* requested init() lays out a static field and attaches no rAF loop and no
* pointer listeners, so the canvas is fully still.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiInfiniteImageField', (config = {}) => ({
tileW: config.tileW ?? 160,
tileH: config.tileH ?? 200,
gap: config.gap ?? 16,
columns: config.columns ?? 8,
rows: config.rows ?? 6,
maxSpeed: config.maxSpeed ?? 12,
smoothing: config.smoothing ?? 0.08,
reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
tiles: [],
// Running offset (accumulated pan) and the smoothed velocity.
offsetX: 0,
offsetY: 0,
velX: 0,
velY: 0,
// Latest pointer position relative to the viewport centre, -1..1 per axis.
pointerX: 0,
pointerY: 0,
active: false,
raf: null,
onMoveBound: null,
onLeaveBound: null,
viewObserver: null,
offscreen: false,
init() {
const strideX = this.tileW + this.gap;
const strideY = this.tileH + this.gap;
this.spanX = this.columns * strideX;
this.spanY = this.rows * strideY;
// Cache each tile with its grid base position. The field is offset by
// one tile so the wrap margin sits off-screen on the leading edges.
const els = Array.from(this.$el.querySelectorAll('[data-slot="infinite-image-field-tile"]'));
this.tiles = els.map((el, i) => {
const col = i % this.columns;
const row = Math.floor(i / this.columns);
return {
el,
baseX: col * strideX - strideX,
baseY: row * strideY - strideY,
};
});
this.layout();
// Reduced motion: a static, fully laid-out field, no loop, no listeners.
if (this.reduced) return;
this.onMoveBound = (event) => this.onMove(event);
this.onLeaveBound = () => { this.active = false; };
this.$el.addEventListener('pointermove', this.onMoveBound);
this.$el.addEventListener('pointerleave', this.onLeaveBound);
this.raf = requestAnimationFrame(() => this.frame());
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't write every tile's transform ~60×/s.
// Resume on re-entry (guarded by the offscreen flag so an idle-park
// is never mistaken for an off-screen pause).
if (typeof IntersectionObserver !== 'undefined') {
this.viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this.raf) {
cancelAnimationFrame(this.raf);
this.raf = null;
this.offscreen = true;
} else if (visible && this.offscreen) {
this.offscreen = false;
this.raf = requestAnimationFrame(() => this.frame());
}
});
this.viewObserver.observe(this.$el);
}
},
destroy() {
if (this.viewObserver) { this.viewObserver.disconnect(); this.viewObserver = null; }
if (this.raf) cancelAnimationFrame(this.raf);
if (this.onMoveBound) this.$el.removeEventListener('pointermove', this.onMoveBound);
if (this.onLeaveBound) this.$el.removeEventListener('pointerleave', this.onLeaveBound);
},
onMove(event) {
const rect = this.$el.getBoundingClientRect();
// -1 at the left/top edge, +1 at the right/bottom edge, 0 at centre.
this.pointerX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.pointerY = ((event.clientY - rect.top) / rect.height) * 2 - 1;
this.active = true;
// Wake the loop if it idle-parked (pointer over $el implies visible).
if (!this.raf && !this.offscreen) {
this.raf = requestAnimationFrame(() => this.frame());
}
},
frame() {
// Target velocity: toward the edge the pointer leans into; faster the
// closer to an edge. Panning the field opposite the pointer makes the
// cursor feel like it is dragging the canvas toward it.
const targetX = this.active ? -this.pointerX * this.maxSpeed : 0;
const targetY = this.active ? -this.pointerY * this.maxSpeed : 0;
// Lerp current velocity toward the target → smooth momentum.
this.velX += (targetX - this.velX) * this.smoothing;
this.velY += (targetY - this.velY) * this.smoothing;
this.offsetX += this.velX;
this.offsetY += this.velY;
this.layout();
// Idle-park: once the pointer has left and momentum has bled off to
// sub-pixel velocity there is nothing to move, so draw the settled
// frame and stop rescheduling. onMove and the IntersectionObserver
// re-entry wake it.
if (!this.active && Math.abs(this.velX) < 0.01 && Math.abs(this.velY) < 0.01) {
this.velX = 0;
this.velY = 0;
this.raf = null;
return;
}
this.raf = requestAnimationFrame(() => this.frame());
},
// Fold the offset modulo the tiled span so the field wraps seamlessly,
// then place every tile relative to that wrapped offset.
layout() {
const ox = this.wrap(this.offsetX, this.spanX);
const oy = this.wrap(this.offsetY, this.spanY);
this.tiles.forEach((tile) => {
const x = this.wrap(tile.baseX + ox, this.spanX) - this.tileW - this.gap;
const y = this.wrap(tile.baseY + oy, this.spanY) - this.tileH - this.gap;
tile.el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
});
},
// Positive modulo so negative offsets wrap correctly.
wrap(value, span) {
return ((value % span) + span) % span;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.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