Image Effects
Cursor- and scroll-driven image effects — trails, an infinite field and ripple refraction.
Image Trail — A premium trail of images (or token-gradient tiles) that spawns sequentially at the cursor as it moves across the area, each tile animating in then fading out.
Preview
<x-ui.image-trail class="h-72 w-full max-w-xl rounded-lg border border-border bg-card">
<span class="grid h-full place-items-center text-sm text-muted-foreground">Move your cursor here</span>
</x-ui.image-trail>
Installation
php artisan ui:add image-trail
Note
This component ships an Alpine behavior module at
resources/js/ui/image-trail.js. Import it once from your bundle so it registers on alpine:init:
import './image-trail.js';
Registry contract
php artisan ui:add image-trail
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/image-trail.blade.php -
resources/js/ui/image-trail.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: Image Trail (`image-trail`)
A premium trail of images (or token-gradient tiles) that spawns sequentially at the cursor as it moves across the area, each tile animating in then fading out.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add image-trail
```
## Usage
```blade
<x-ui.image-trail class="h-72 w-full max-w-xl rounded-lg border border-border bg-card">
<span class="grid h-full place-items-center text-sm text-muted-foreground">Move your cursor here</span>
</x-ui.image-trail>
```
## Props
- `images` (array, default `[]`) — Array of image URLs spawned along the pointer trail; empty by default, which falls back to gradient tiles.
- `imageWidth` (int, default `200`) — Pixel width of each spawned tile.
- `imageHeight` (int, default `200`) — Pixel height of each spawned tile.
- `threshold` (int, default `50`) — Pointer travel distance, in pixels, between spawning tiles.
- `duration` (float, default `1.6`) — Seconds each tile takes to animate in and fade out.
## Use when
- Use only when visual expression supports storytelling, brand differentiation, or a clear showcase moment.
- Leaving a trail of images behind the pointer as it moves across a hero or gallery section, for a playful showcase effect.
## Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The images need to stay individually accessible or clickable at rest; use a normal image grid or carousel instead.
## Anti-patterns
- Heavy effects in forms or task-dense workflows
- Motion that interferes with reading or focus
## Rules
- Use the `<brok:image-trail>` tag (or `<x-ui.image-trail>`) 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/image-trail
- Registry JSON (files, props, contract): https://brokui.dev/r/open/image-trail.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 spawned along the pointer trail; empty by default, which falls back to gradient tiles. |
| imageWidth | int | 200 | Pixel width of each spawned tile. |
| imageHeight | int | 200 | Pixel height of each spawned tile. |
| threshold | int | 50 | Pointer travel distance, in pixels, between spawning tiles. |
| duration | float | 1.6 | Seconds each tile takes to animate in and fade out. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Spawns an image tile from the images list each time the pointer travels past the distance threshold, animating it in and fading it out over duration seconds.
- With an empty images array it cycles through six token-gradient tiles instead, so the demo 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.
- Leaving a trail of images behind the pointer as it moves across a hero or gallery section, for a playful showcase effect.
Avoid when
- Do not place heavy effects in task-dense workflows where clarity, speed, and readability matter more.
- The images need to stay individually accessible or clickable at rest; use a normal image grid or carousel 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 trailing tiles are a purely visual effect driven by pointer movement; give the surrounding section its own heading or text so the content is not conveyed only through hover.
- 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.image-trail class="h-72 w-full max-w-xl rounded-lg border border-border bg-card">
<span class="grid h-full place-items-center text-sm text-muted-foreground">Move your cursor here</span>
</x-ui.image-trail>
</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' => 200,
'imageHeight' => 200,
'threshold' => 50,
'duration' => 1.6,
])
@php
// Normalise the URL list. When empty we fall back to six token-gradient
// tiles so the demo is self-contained, themeable and ships no binaries.
$images = array_values(array_filter(array_map('strval', (array) $images), fn ($u) => $u !== ''));
// Gradient classes cycle per index for the asset-free fallback. Tokens only.
$gradients = [
'bg-gradient-to-br from-primary to-accent',
'bg-gradient-to-br from-accent to-secondary',
'bg-gradient-to-br from-secondary to-primary',
'bg-gradient-to-tr from-primary to-secondary',
'bg-gradient-to-tr from-accent to-primary',
'bg-gradient-to-bl from-secondary to-accent',
];
$config = [
'images' => $images,
'gradients' => $gradients,
'imageWidth' => (int) $imageWidth,
'imageHeight' => (int) $imageHeight,
'threshold' => (float) $threshold,
'duration' => (float) $duration,
];
@endphp
<div
x-data="uiImageTrail(@js($config))"
x-ref="root"
data-slot="image-trail"
@pointermove="onPointerMove($event)"
{{ $attributes->merge(['class' => 'relative overflow-hidden bg-card text-foreground']) }}
>
{{ $slot }}
</div>
/**
* Image Trail behavior.
*
* As the pointer moves across the root, we track how far it has travelled since
* the last spawn. Once that distance crosses `threshold` px, we spawn the NEXT
* tile from a cycling source array at the cursor position — producing a
* sequential trail rather than a burst. Each tile is an absolutely-positioned,
* pointer-events-none, aria-hidden element appended to the root; its enter
* (scale/opacity up) and exit (fade + scale down) are driven by CSS transitions
* on the `data-state` attribute, so no GSAP / npm deps are needed.
*
* Tiles are recycled: at most `_max` live at once. When the cap is reached the
* oldest tile is removed before a new one is appended, keeping the DOM bounded.
* `destroy()` clears every pending timeout and removes all tiles + listeners.
*
* When `images` URLs are supplied each tile is an <img>; otherwise we render a
* token-gradient <div> (themeable, asset-free), cycling through the gradient
* class list per index.
*
* Reduced-motion: the pointer listener is never wired up (handler bails), so the
* root stays static — just the slot/hint content, no trail.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiImageTrail', (config = {}) => ({
images: Array.isArray(config.images) ? config.images : [],
gradients: Array.isArray(config.gradients) && config.gradients.length
? config.gradients
: ['bg-gradient-to-br from-primary to-accent'],
imageWidth: config.imageWidth ?? 200,
imageHeight: config.imageHeight ?? 200,
threshold: config.threshold ?? 50,
duration: config.duration ?? 1.6,
_reduced: false,
// Index into the cycling source array for the next spawn.
_next: 0,
// Last pointer position used to measure travelled distance.
_lastX: null,
_lastY: null,
// Live tiles ({ el, timeout }) and a hard cap to keep the DOM bounded.
_tiles: [],
_max: 16,
init() {
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
},
onPointerMove(event) {
if (this._reduced) {
return;
}
const root = this.$refs.root ?? this.$el;
const rect = root.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (this._lastX === null) {
// First sample: anchor without spawning so the trail begins on
// the next real movement.
this._lastX = x;
this._lastY = y;
return;
}
const travelled = Math.hypot(x - this._lastX, y - this._lastY);
if (travelled < this.threshold) {
return;
}
this._lastX = x;
this._lastY = y;
this._spawn(x, y);
},
_spawn(x, y) {
const root = this.$refs.root ?? this.$el;
// Recycle the oldest tile once the cap is reached.
if (this._tiles.length >= this._max) {
const oldest = this._tiles.shift();
if (oldest) {
clearTimeout(oldest.timeout);
oldest.el.remove();
}
}
const count = this.images.length || this.gradients.length;
const index = this._next % count;
this._next = (this._next + 1) % count;
const tile = this.images.length
? this._makeImage(index)
: this._makeGradient(index);
tile.setAttribute('aria-hidden', 'true');
tile.dataset.slot = 'image-trail-item';
tile.style.position = 'absolute';
tile.style.left = '0px';
tile.style.top = '0px';
tile.style.width = `${this.imageWidth}px`;
tile.style.height = `${this.imageHeight}px`;
tile.style.pointerEvents = 'none';
tile.style.willChange = 'transform, opacity';
tile.style.borderRadius = '0.75rem';
tile.style.overflow = 'hidden';
// Center the tile on the cursor.
tile.style.transform = `translate(${x - this.imageWidth / 2}px, ${y - this.imageHeight / 2}px) scale(0.4)`;
tile.style.opacity = '0';
tile.style.transition = `transform ${this.duration}s cubic-bezier(0.22, 1, 0.36, 1), opacity ${this.duration}s ease`;
root.appendChild(tile);
const record = { el: tile, timeout: null };
this._tiles.push(record);
// Enter on the next frame so the initial state is committed first.
requestAnimationFrame(() => {
tile.style.transform = `translate(${x - this.imageWidth / 2}px, ${y - this.imageHeight / 2}px) scale(1)`;
tile.style.opacity = '1';
// Then schedule the exit shortly after, letting the same
// transition fade + shrink it out before removal.
record.timeout = setTimeout(() => {
tile.style.opacity = '0';
tile.style.transform = `translate(${x - this.imageWidth / 2}px, ${y - this.imageHeight / 2}px) scale(0.6)`;
record.timeout = setTimeout(() => {
tile.remove();
const i = this._tiles.indexOf(record);
if (i !== -1) {
this._tiles.splice(i, 1);
}
}, this.duration * 1000);
}, this.duration * 1000 * 0.25);
});
},
_makeImage(index) {
const img = document.createElement('img');
img.src = this.images[index];
img.alt = '';
img.style.objectFit = 'cover';
img.style.width = '100%';
img.style.height = '100%';
return img;
},
_makeGradient(index) {
const div = document.createElement('div');
div.className = this.gradients[index % this.gradients.length];
return div;
},
destroy() {
for (const record of this._tiles) {
clearTimeout(record.timeout);
record.el.remove();
}
this._tiles = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.2 - 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