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.4
Stability
stable
License
MIT
Related
Image Trail
Image Ripple Effect

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.

Preview

previews.components.infinite-image-field.default.blade.php Blade
<x-ui.infinite-image-field class="h-80 w-full max-w-2xl rounded-lg border border-border" />

Installation

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

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

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

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

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 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.

infinite-image-field infinite-image-field-tile

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

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.
  • 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
root infinite-image-field-tile
Theming hooks
infinite-image-field infinite-image-field-tile

Accessibility

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

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