Skip to content
Brok UI

Loading…

No results

Reorder

Open source

Drag-to-arrange surfaces — a kanban board, a sortable list and a swap-in-place grid.

Version
v1.1.1
Stability
stable
License
MIT
Related
Sortable
Kanban

Swappable Grid — A grid whose tiles exchange positions as you drag one over another (the lifted tile follows the pointer, the displaced tile glides into its slot), built natively in Alpine — keyboard-operable (pick with Enter, swap with arrows) with a live-region announcement, and a name to post the layout.

Preview

Overview
Revenue
Traffic
Sessions
Signups
Churn
previews.components.swappable-grid.default.blade.php Blade
<x-ui.swappable-grid class="mx-auto w-full max-w-lg" :columns="3" />

Installation

terminal
php artisan ui:add swappable-grid

Note

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

resources/js/ui/index.js JS
import './swappable-grid.js';

Registry contract

php artisan ui:add swappable-grid 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/swappable-grid.blade.php
  • js resources/js/ui/swappable-grid.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.

swappable-grid.md
# Brok UI: Swappable Grid (`swappable-grid`)

A grid whose tiles exchange positions as you drag one over another (the lifted tile follows the pointer, the displaced tile glides into its slot), built natively in Alpine — keyboard-operable (pick with Enter, swap with arrows) with a live-region announcement, and a name to post the layout.

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

## Install

```bash
php artisan ui:add swappable-grid
```

## Usage

```blade
<x-ui.swappable-grid class="mx-auto w-full max-w-lg" :columns="3" />
```

## Props

- `items` (array, default `[]`) — Array of tiles, each with a label and an optional id.
- `columns` (int, default `3`) — Column count: 2, 3, or 4.
- `name` (mixed|null, default `null`) — When set, posts each slot's tile id as name[], in current grid order, for saving the layout.

## Use when

- Use to group related content into scannable units for browsing, prioritization, or rearrangement.
- Letting users rearrange a fixed set of tiles, such as dashboard widgets, in a grid by swapping positions two at a time.
- Needing the arrangement to post back in a plain form via a name array.

## Avoid when

- Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
- Reordering a single vertical list; use sortable instead.
- Items need to move between grouped columns; use kanban.

## Anti-patterns

- Turning every object into a card

## Rules

- Use the `<brok:swappable-grid>` tag (or `<x-ui.swappable-grid>`) 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/swappable-grid
- Registry JSON (files, props, contract): https://brokui.dev/r/open/swappable-grid.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
items array [] Array of tiles, each with a label and an optional id.
columns int 3 Column count: 2, 3, or 4.
name mixed | null null When set, posts each slot's tile id as name[], in current grid order, for saving the layout.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

swappable-grid swappable-grid-item

Behavior

  • Dragging a tile over another exchanges their two positions at once; the displaced tile animates (FLIP) into the dragged tile's old slot.
  • Each cell keeps its DOM position; only its CSS order changes, so swaps are cheap and reduced-motion safe.
  • Fully keyboard-operable: Enter or Space picks a tile, arrow keys swap it with the neighbour in that direction (RTL-aware), and Escape cancels the pick.
  • A polite live region announces every swap to assistive tech.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, responsive, rtl, darkMode, localized, interactive, reducedMotion.

Guidance

Card-based organization

Organize browsable objects into scannable units.

Use when

  • Use to group related content into scannable units for browsing, prioritization, or rearrangement.
  • Letting users rearrange a fixed set of tiles, such as dashboard widgets, in a grid by swapping positions two at a time.
  • Needing the arrangement to post back in a plain form via a name array.

Avoid when

  • Do not use card-like structures when dense row-by-row comparison would be clearer in a list or table.
  • Reordering a single vertical list; use sortable instead.
  • Items need to move between grouped columns; use kanban.

Use instead

  • Table for exact comparison
  • List for compact scanning

Anti-patterns

  • Turning every object into a card
Anatomy
root swappable-grid-item
Theming hooks
swappable-grid swappable-grid-item

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape
Focus
managed
  • Every tile is a focusable, keyboard-operable role="button" with aria-pressed reflecting the picked state, so swapping works fully without a pointer.
  • A polite live region announces each swap, giving screen reader users feedback that a drag or keyboard swap completed.
  • 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="swappable-grid-{{ $record->id }}">
    <x-ui.swappable-grid class="mx-auto w-full max-w-lg" :columns="3" />
</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:swappable-grid
        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/swappable-grid.blade.php Blade
@props([
    'items' => [],
    'columns' => 3,
    // Posts each slot's tile id as `name[]`, in grid order, for saving the layout.
    'name' => null,
])

@php
    // Swappable Grid — tiles occupy grid slots and exchange positions, à la
    // Swapy, built natively in Alpine (no extra library). A pointer lifts a tile
    // and it follows; passing over another tile swaps the two at once and the
    // displaced tile glides into its slot (FLIP); release settles the lifted
    // tile. Each cell keeps its DOM position; only its CSS `order` changes.
    // Fully keyboard-operable: Enter/Space picks a tile, Arrow keys swap it with
    // the neighbour in that direction (RTL-aware), Escape cancels; a polite live
    // region announces every swap. `name` posts the tile ids in slot order.
    $columns = in_array((int) $columns, [2, 3, 4], true) ? (int) $columns : 3;
    $colsClass = ['2' => 'grid-cols-2', '3' => 'grid-cols-3', '4' => 'grid-cols-4'][(string) $columns];

    $items = ! empty($items) ? $items : [
        ['label' => 'Overview'],
        ['label' => 'Revenue'],
        ['label' => 'Traffic'],
        ['label' => 'Sessions'],
        ['label' => 'Signups'],
        ['label' => 'Churn'],
    ];
    $count = count($items);
@endphp

<div
    data-slot="swappable-grid"
    x-data="uiSwappableGrid({ count: {{ $count }}, columns: {{ $columns }} })"
    role="group"
    aria-label="{{ __('Rearrangeable grid — pick a tile with Enter, then arrow keys to swap') }}"
    {{ $attributes->merge(['class' => 'relative grid gap-3 '.$colsClass]) }}
>
    @foreach ($items as $i => $item)
        <div
            data-slot="swappable-grid-item"
            role="button"
            tabindex="0"
            :style="'order:' + order[{{ $i }}] + (dragging === {{ $i }} ? '; transform:' + dragTransform : '')"
            @pointerdown="startDrag({{ $i }}, $event)"
            @keydown="onKey({{ $i }}, $event)"
            :class="{
                'ring-2 ring-ring border-primary': isPicked({{ $i }}),
                'border-border': !isPicked({{ $i }}),
                'relative z-10 scale-[1.03] shadow-lg cursor-grabbing': dragging === {{ $i }},
                'transition-shadow hover:shadow-md': dragging !== {{ $i }},
            }"
            :aria-pressed="isPicked({{ $i }})"
            :data-dragging="dragging === {{ $i }} || null"
            class="flex cursor-grab touch-none select-none items-center gap-2 rounded-lg border bg-card p-6 text-card-foreground shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
        >
            <svg aria-hidden="true" class="size-4 shrink-0 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
            <span class="truncate text-sm font-medium">{{ __($item['label'] ?? 'Item') }}</span>
        </div>
    @endforeach

    @if ($name)
        {{-- One hidden input per slot, so the posted array reads in grid order. --}}
        @foreach ($items as $i => $item)
            <input type="hidden" name="{{ $name }}[]" :value="{{ Js::from(array_map(fn ($it) => (string) ($it['id'] ?? $it['label'] ?? ''), $items)) }}[order.indexOf({{ $i }})]" />
        @endforeach
    @endif

    <span class="sr-only" role="status" aria-live="polite" x-text="announce"></span>
</div>
resources/js/ui/swappable-grid.js JS
/**
 * Swappable Grid behavior.
 *
 * Maintains `order` — a per-item array of CSS grid `order` values. Swapping two
 * items exchanges their order values, so the DOM never moves (no focus loss).
 *
 * Pointer: pointerdown lifts a tile, which then follows the pointer; passing
 * over another tile swaps the two at once, the displaced tile gliding into its
 * new slot (FLIP: layout positions are read before and after the swap and the
 * difference is animated away). Releasing lets the lifted tile settle into
 * its slot the same way. Keyboard: Enter/Space picks a tile, an Arrow key swaps
 * it with the neighbour in that direction (RTL-aware), Escape cancels; those
 * swaps glide too. Every swap is announced through the polite live region the
 * Blade renders and dispatched as `swappable-grid:change` with the order.
 * Reduced motion skips the glides.
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSwappableGrid', (config = {}) => ({
        count: config.count ?? 0,
        columns: config.columns ?? 3,
        order: Array.from({ length: config.count ?? 0 }, (_, i) => i),
        picked: null,
        dragging: null,
        over: null,
        dragTransform: '',
        announce: '',
        root: null,
        _grab: null,
        _lastSwap: null,
        _move: null,
        _up: null,
        _reduced: false,

        init() {
            this.root = this.$el;
            this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        },

        tiles() {
            return [...this.root.querySelectorAll('[data-slot="swappable-grid-item"]')];
        },

        isPicked(i) {
            return this.picked === i;
        },

        /** Layout position of every tile (offset*, so a lifted tile's transform does not count). */
        snapshot() {
            return this.tiles().map((el) => [el.offsetLeft, el.offsetTop]);
        },

        /** Exchange two slots and glide the tiles that moved (the lifted one keeps following the pointer). */
        swap(a, b) {
            const before = this.snapshot();
            const tmp = this.order[a];
            this.order[a] = this.order[b];
            this.order[b] = tmp;
            this.announce = `Swapped item ${a + 1} and item ${b + 1}.`;
            this.$dispatch('swappable-grid:change', { order: [...this.order] });
            if (this._reduced) return;
            this.$nextTick(() => {
                const after = this.snapshot();
                this.tiles().forEach((el, i) => {
                    if (i === this.dragging) return;
                    const dx = before[i][0] - after[i][0];
                    const dy = before[i][1] - after[i][1];
                    if (dx || dy) {
                        el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 220, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
                    }
                });
            });
        },

        // Pointer drag: the tile follows the pointer; passing over another tile swaps.
        startDrag(i, e) {
            if (e.button !== undefined && e.button !== 0) return;
            const el = this.tiles()[i];
            const rect = el.getBoundingClientRect();
            this._grab = { x: e.clientX - rect.left, y: e.clientY - rect.top };
            this.dragging = i;
            this.picked = null;
            this.updateTransform(e.clientX, e.clientY);

            this._move = (ev) => {
                this.updateTransform(ev.clientX, ev.clientY);
                const target = this.tileAt(ev.clientX, ev.clientY);
                this.over = target;
                // The displaced tile is still under the pointer while it glides
                // away; do not swap with it again until the pointer has left it.
                if (target === null) this._lastSwap = null;
                if (target !== null && target !== this.dragging && target !== this._lastSwap) {
                    this._lastSwap = target;
                    this.swap(this.dragging, target);
                }
            };
            this._up = () => this.endDrag();
            document.addEventListener('pointermove', this._move);
            document.addEventListener('pointerup', this._up);
            document.addEventListener('pointercancel', this._up);
            e.preventDefault();
        },

        /** Translate = where the pointer wants the tile minus where its slot lays it out. */
        updateTransform(clientX, clientY) {
            const el = this.tiles()[this.dragging];
            if (!el) return;
            const grid = this.root.getBoundingClientRect();
            const x = clientX - this._grab.x - grid.left - el.offsetLeft;
            const y = clientY - this._grab.y - grid.top - el.offsetTop;
            this.dragTransform = `translate(${x}px, ${y}px)`;
        },

        tileAt(x, y) {
            const hit = document.elementsFromPoint(x, y).find((n) => n.matches?.('[data-slot="swappable-grid-item"]') && this.tiles().indexOf(n) !== this.dragging);
            return hit ? this.tiles().indexOf(hit) : null;
        },

        endDrag() {
            document.removeEventListener('pointermove', this._move);
            document.removeEventListener('pointerup', this._up);
            document.removeEventListener('pointercancel', this._up);
            const i = this.dragging;
            const el = this.tiles()[i];
            const from = this.dragTransform;
            this._lastSwap = null;
            this.dragging = null;
            this.over = null;
            this.dragTransform = '';
            if (el && from && !this._reduced) {
                // Settle from the last pointer position into the slot.
                el.animate([{ transform: from }, { transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
            }
        },

        // Keyboard.
        toggle(i) {
            if (this.picked === null) {
                this.picked = i;
                this.announce = `Picked item ${i + 1}. Use the arrow keys to swap it, then Enter to drop.`;
            } else if (this.picked === i) {
                this.picked = null;
                this.announce = 'Dropped.';
            } else {
                this.swap(this.picked, i);
                this.picked = null;
            }
        },

        move(dir) {
            if (this.picked === null) return;
            const pos = this.order[this.picked];
            let target = pos;
            if (dir === 'left') target = pos - 1;
            else if (dir === 'right') target = pos + 1;
            else if (dir === 'up') target = pos - this.columns;
            else if (dir === 'down') target = pos + this.columns;
            if (target < 0 || target >= this.count) return;

            const other = this.order.indexOf(target);
            if (other === -1) return;
            this.swap(this.picked, other);
        },

        onKey(i, e) {
            if (e.key === ' ' || e.key === 'Enter') {
                e.preventDefault();
                this.toggle(i);
                return;
            }
            if (e.key === 'Escape') {
                this.picked = null;
                this.announce = 'Cancelled.';
                return;
            }

            const rtl = getComputedStyle(this.root).direction === 'rtl';
            const map = {
                ArrowLeft: rtl ? 'right' : 'left',
                ArrowRight: rtl ? 'left' : 'right',
                ArrowUp: 'up',
                ArrowDown: 'down',
            };
            if (map[e.key] && this.picked !== null) {
                e.preventDefault();
                this.move(map[e.key]);
            }
        },

        destroy() {
            if (this._move) document.removeEventListener('pointermove', this._move);
            if (this._up) {
                document.removeEventListener('pointerup', this._up);
                document.removeEventListener('pointercancel', this._up);
            }
            this.picked = null;
            this.dragging = null;
        },
    }));
});

Ownership & lifecycle

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