Skip to content
Brok UI

Loading…

No results

Image Picker

Open source

A grid of image options with keyboard support. Single-select by default; `multiple` turns it into a multi-select gallery with a live count + Reset action bar.

Version
v2.2.2
Stability
stable
License
MIT
Related
Dropzone
Image Mask
Compare Slider

Preview

previews.components.image-picker.default.blade.php Blade
<x-ui.image-picker :selected="0" class="mx-auto w-full max-w-xs" />

Installation

terminal
php artisan ui:add image-picker

Note

This component ships an Alpine behavior module at resources/js/ui/image-picker.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './image-picker.js';

Registry contract

php artisan ui:add image-picker 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-picker.blade.php
  • js resources/js/ui/image-picker.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-picker.md
# Brok UI: Image Picker (`image-picker`)

A grid of image options with keyboard support. Single-select by default; `multiple` turns it into a multi-select gallery with a live count + Reset action bar.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add image-picker
```

## Usage

```blade
<x-ui.image-picker :selected="0" class="mx-auto w-full max-w-xs" />
```

## Props

- `images` (array, default `[…]`) — Array of image options, each with a src and an id or value.
- `selected` (mixed|null, default `null`) — Initially selected value, or array of values when multiple is set.
- `columns` (mixed|null, default `null`) — Grid column count; defaults to a responsive auto column count when unset.
- `multiple` (bool, default `false`) — Allows selecting more than one image and shows a live count plus a Reset action bar.

## Use when

- Use when image or video selection, preview, or media-based comparison is part of the task.
- Letting a user choose one or more images from a visual gallery, such as picking a cover photo or gallery images.
- Needing keyboard navigation and a live selection count in a grid of image options.

## Avoid when

- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- Choosing from a small set of non-visual options; use radio, choicebox, or label-selector instead.
- Uploading a new image rather than selecting from an existing set; pair with a file upload control instead.

## Anti-patterns

- Requiring media-heavy interaction when text is faster

## Rules

- Use the `<brok:image-picker>` tag (or `<x-ui.image-picker>`) 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-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/image-picker.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 options, each with a src and an id or value.
selected mixed | null null Initially selected value, or array of values when multiple is set.
columns mixed | null null Grid column count; defaults to a responsive auto column count when unset.
multiple bool false Allows selecting more than one image and shows a live count plus a Reset action bar.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

interactive-image-selector {{ $slotName }}

Behavior

  • Clicking or using the keyboard to activate an image toggles its selection; single-select mode replaces the current selection, multiple mode adds to it.
  • With multiple set, a live count and a Reset action appear once at least one image is selected.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Media selection and presentation

Select, preview, or compare visual media.

Use when

  • Use when image or video selection, preview, or media-based comparison is part of the task.
  • Letting a user choose one or more images from a visual gallery, such as picking a cover photo or gallery images.
  • Needing keyboard navigation and a live selection count in a grid of image options.

Avoid when

  • Do not require media-heavy interaction when a simpler text or list solution would be faster.
  • Choosing from a small set of non-visual options; use radio, choicebox, or label-selector instead.
  • Uploading a new image rather than selecting from an existing set; pair with a file upload control instead.

Use instead

  • Text or list representation

Anti-patterns

  • Requiring media-heavy interaction when text is faster
Anatomy
root interactive-image-selector
Theming hooks
interactive-image-selector {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • The grid supports full keyboard navigation between images, so selection is not limited to pointer clicks.
  • Selected state is exposed through ARIA attributes on each image option, not colour or a checkmark icon alone.
  • 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:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="image-picker-{{ $record->id }}">
    <x-ui.image-picker :selected="0" class="mx-auto w-full max-w-xs" />
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:image-picker
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

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-picker.blade.php Blade
@props([
    'images' => [
        'https://picsum.photos/seed/pick1/300',
        'https://picsum.photos/seed/pick2/300',
        'https://picsum.photos/seed/pick3/300',
        'https://picsum.photos/seed/pick4/300',
    ],
    'selected' => null,
    // Tiles per row: 2 | 3 | 4. Defaults to 2 (single) or 3 (multiple).
    'columns' => null,
    // false (default): exactly one tile can be chosen. true: any number of
    // tiles can be chosen, with a live count + Reset action bar.
    'multiple' => false,
])

@php
    $multiple = filter_var($multiple, FILTER_VALIDATE_BOOLEAN);
    $images = array_values($images);

    // Fixed column maps keep the classes visible to the Tailwind JIT scanner.
    $columnClasses = [
        2 => 'grid-cols-2',
        3 => 'grid-cols-2 sm:grid-cols-3',
        4 => 'grid-cols-2 sm:grid-cols-4',
    ];
    $defaultColumns = $multiple ? 3 : 2;
    $columns = $columns !== null ? (int) $columns : $defaultColumns;
    $columns = array_key_exists($columns, $columnClasses) ? $columns : $defaultColumns;
    $gridClass = $columnClasses[$columns];

    // interactive-image-selector composes this as <x-ui.image-picker multiple
    // data-slot="interactive-image-selector">; the browser keeps only the
    // first of two identical attributes, so read the override and print
    // exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'image-picker');

    if ($multiple) {
        $config = [
            'multiple' => true,
            'selected' => array_values(array_map('intval', (array) ($selected ?? []))),
            'total' => count($images),
        ];
    } else {
        $config = [
            'multiple' => false,
            'selected' => is_null($selected) ? null : (int) $selected,
        ];
    }
@endphp

{{--
    Image picker — a grid of options. By default exactly one can be chosen
    (choose(i) toggles the active index; re-selecting clears it). With
    `multiple`, any number can be chosen (toggle(i)/has(i)/reset()) behind a
    live "N selected" + Reset action bar, and the selection mirrors to
    data-selected for the host to read — the interactive-image-selector shape.
    Each tile is a button with :aria-pressed; the active tile gets a primary
    ring plus a check badge. Images are decorative crops, so object-cover
    fills the square frame.
--}}
@if ($multiple)
    <div
        x-data="uiImagePicker(@js($config))"
        data-slot="{{ $slotName }}"
        :data-selected="JSON.stringify(items)"
        {{ $attributes->except('data-slot')->merge(['class' => 'flex flex-col gap-3']) }}
    >
        <div class="flex items-center justify-between gap-3">
            <p class="text-sm font-medium text-foreground" aria-live="polite">
                <span x-text="count"></span> {{ __('selected') }}
            </p>
            <button
                type="button"
                @click="reset()"
                :disabled="count === 0"
                class="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"
            >
                {{ __('Reset') }}
            </button>
        </div>

        <div
            role="group"
            aria-label="{{ __('Selectable images') }}"
            class="grid {{ $gridClass }} gap-2"
        >
            @foreach ($images as $i => $src)
                <button
                    type="button"
                    @click="choose({{ $i }})"
                    :aria-pressed="has({{ $i }})"
                    :class="has({{ $i }}) ? 'ring-2 ring-primary' : 'ring-0 ring-transparent'"
                    aria-label="{{ __('Image') . ' ' . ($i + 1) }}"
                    class="group relative aspect-square overflow-hidden rounded-lg bg-muted transition-[box-shadow] duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none"
                >
                    <img
                        src="{{ $src }}"
                        alt="{{ __('Image') . ' ' . ($i + 1) }}"
                        loading="lazy"
                        :class="has({{ $i }}) ? 'scale-[0.88] rounded-md' : 'scale-100 rounded-none motion-safe:group-hover:scale-105'"
                        class="size-full object-cover transition-[transform,border-radius] duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none"
                    />
                    <span
                        x-show="has({{ $i }})"
                        x-cloak
                        x-transition:enter="transition duration-200 ease-out motion-reduce:transition-none"
                        x-transition:enter-start="scale-50 opacity-0"
                        x-transition:enter-end="scale-100 opacity-100"
                        x-transition:leave="transition duration-150 ease-in motion-reduce:transition-none"
                        x-transition:leave-start="scale-100 opacity-100"
                        x-transition:leave-end="scale-50 opacity-0"
                        aria-hidden="true"
                        class="absolute end-2 top-2 inline-flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md ring-2 ring-background"
                    >
                        <svg viewBox="0 0 20 20" fill="currentColor" class="size-4">
                            <path fill-rule="evenodd" d="M16.704 5.29a1 1 0 0 1 .006 1.414l-7.5 7.6a1 1 0 0 1-1.42.006l-3.5-3.5a1 1 0 0 1 1.414-1.414l2.79 2.79 6.793-6.886a1 1 0 0 1 1.417-.006Z" clip-rule="evenodd" />
                        </svg>
                    </span>
                </button>
            @endforeach
        </div>
    </div>
@else
    <div
        x-data="uiImagePicker(@js($config))"
        data-slot="{{ $slotName }}"
        role="group"
        aria-label="{{ __('Images') }}"
        {{ $attributes->except('data-slot')->merge(['class' => 'grid '.$gridClass.' gap-3']) }}
    >
        @foreach ($images as $i => $src)
            <button
                type="button"
                @click="choose({{ $i }})"
                :aria-pressed="selected === {{ $i }}"
                :class="selected === {{ $i }} ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : 'ring-0 ring-transparent hover:ring-2 hover:ring-border hover:ring-offset-2 hover:ring-offset-background'"
                aria-label="{{ __('Image') . ' ' . ($i + 1) }}"
                class="group relative aspect-square overflow-hidden rounded-lg bg-muted transition-[box-shadow] duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none"
            >
                <img src="{{ $src }}" alt="{{ __('Image') . ' ' . ($i + 1) }}" loading="lazy" class="size-full object-cover transition-transform duration-300 ease-out motion-safe:group-hover:scale-105 motion-reduce:transition-none" />
                <span
                    x-show="selected === {{ $i }}"
                    x-cloak
                    x-transition:enter="transition duration-200 ease-out motion-reduce:transition-none"
                    x-transition:enter-start="scale-50 opacity-0"
                    x-transition:enter-end="scale-100 opacity-100"
                    x-transition:leave="transition duration-150 ease-in motion-reduce:transition-none"
                    x-transition:leave-start="scale-100 opacity-100"
                    x-transition:leave-end="scale-50 opacity-0"
                    aria-hidden="true"
                    class="absolute end-2 top-2 inline-flex size-6 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-md ring-2 ring-background"
                >
                    <svg viewBox="0 0 20 20" fill="currentColor" class="size-4">
                        <path fill-rule="evenodd" d="M16.704 5.29a1 1 0 0 1 .006 1.414l-7.5 7.6a1 1 0 0 1-1.42.006l-3.5-3.5a1 1 0 0 1 1.414-1.414l2.79 2.79 6.793-6.886a1 1 0 0 1 1.417-.006Z" clip-rule="evenodd" />
                    </svg>
                </span>
            </button>
        @endforeach
    </div>
@endif
resources/js/ui/image-picker.js JS
/**
 * Image picker behavior.
 *
 * Default (single-select): a grid of image tiles where exactly one can be
 * chosen. choose(i) toggles the active index (re-selecting the current tile
 * clears it) and dispatches `image-picker-select` with the chosen index for
 * the host to act on.
 *
 * `multiple`: any number of tiles can be chosen. Selected indices are kept in
 * a Set; choose(i) toggles membership, has(i) reports it, count exposes the
 * live size, and reset() clears the selection. Every mutation dispatches
 * `image-selector-change` with the current sorted index array (also mirrored
 * to the root's `data-selected` attribute by the Blade template).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiImagePicker', (cfg = {}) => ({
        multiple: !!cfg.multiple,
        total: cfg.total ?? 0,
        selected: cfg.multiple ? null : (typeof cfg.selected === 'number' ? cfg.selected : null),

        /** Selected indices as a Set for O(1) membership + de-duplication (multiple only). */
        _set: new Set(),

        init() {
            if (!this.multiple) return;
            const initial = Array.isArray(cfg.selected) ? cfg.selected : [];
            this._set = new Set(
                initial
                    .map((n) => Number(n))
                    .filter((n) => Number.isInteger(n) && n >= 0 && n < this.total)
            );
        },

        /** Sorted array of selected indices (reactive read for templates/host; multiple only). */
        get items() {
            return [...this._set].sort((a, b) => a - b);
        },

        get count() {
            return this._set.size;
        },

        has(i) {
            return this.multiple ? this._set.has(i) : this.selected === i;
        },

        choose(i) {
            if (this.multiple) {
                if (this._set.has(i)) {
                    this._set.delete(i);
                } else {
                    this._set.add(i);
                }
                // Reassign so Alpine tracks the change on getters reading `_set`.
                this._set = new Set(this._set);
                this.emit();

                return;
            }

            this.selected = this.selected === i ? null : i;
            this.$el.dispatchEvent(
                new CustomEvent('image-picker-select', {
                    detail: { index: this.selected },
                    bubbles: true,
                })
            );
        },

        reset() {
            if (!this.multiple || this._set.size === 0) return;
            this._set = new Set();
            this.emit();
        },

        emit() {
            this.$el.dispatchEvent(
                new CustomEvent('image-selector-change', {
                    detail: { selected: this.items },
                    bubbles: true,
                })
            );
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
2.2.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