Reorder
Drag-to-arrange surfaces — a kanban board, a sortable list and a swap-in-place 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.
Preview
<x-ui.swappable-grid class="mx-auto w-full max-w-lg" :columns="3" />
Installation
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:
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.
-
resources/views/components/ui/swappable-grid.blade.php -
resources/js/ui/swappable-grid.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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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 a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
/**
* 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