Skip to content
Brok UI

Loading…

No results

Image Effects

Open source

Cursor- and scroll-driven image effects — trails, an infinite field and ripple refraction.

Version
v1.0.2
Stability
stable
License
MIT
Related
Infinite Image Field
Image Ripple Effect

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

Move your cursor here
previews.components.image-trail.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/image-trail.blade.php
  • js resources/js/ui/image-trail.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.

image-trail.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

image-trail

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

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.
  • 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
root
Theming hooks
image-trail

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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.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.

resources/views/components/ui/image-trail.blade.php Blade
@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>
resources/js/ui/image-trail.js JS
/**
 * 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