Media Collections
Data-driven image sets with a zoom lightbox — a masonry or uniform tile grid, and an ordered, reorderable strip of screen-flow steps.
Flow Strip — An ordered, numbered strip of screen images for a user flow: one horizontal, scroll-snapping row joined by connectors, with an image-viewer lightbox per step and, when reorderable, Move earlier and Move later buttons plus a drag handle that also takes arrow keys, hidden inputs in step order and a change event.
Preview
Arrow keys move this step. Home and End move it to the start or the end.
{{-- With Livewire: x-on:flow-strip:change="$wire.reorder($event.detail.order)"
and render the steps in the saved order. --}}
<x-ui.flow-strip
reorderable
name="steps"
label="{{ __('Sign-up flow') }}"
:steps="[
['id' => 'sign-up', 'src' => asset('thumbs/block/auth-sign-up.webp'), 'alt' => __('Sign-up form with name, email and password'), 'width' => 960, 'height' => 600, 'title' => __('Sign up')],
['id' => 'verify-email', 'src' => asset('thumbs/block/auth-verify-email.webp'), 'alt' => __('Verify email notice with a resend button'), 'width' => 960, 'height' => 600, 'title' => __('Verify email')],
['id' => 'two-factor', 'src' => asset('thumbs/block/auth-two-factor.webp'), 'alt' => __('Two-factor code entry'), 'width' => 960, 'height' => 600, 'title' => __('Two-factor')],
['id' => 'accept-invite', 'src' => asset('thumbs/block/accept-invite.webp'), 'alt' => __('Team invitation with an accept button'), 'width' => 960, 'height' => 600, 'title' => __('Accept invite')],
['id' => 'sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in')],
]"
/>
Installation
php artisan ui:add flow-strip
Note
This component ships an Alpine behavior module at
resources/js/ui/flow-strip.js. Import it once from your bundle so it registers on alpine:init:
import './flow-strip.js';
Registry contract
php artisan ui:add flow-strip
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/flow-strip.blade.php -
resources/views/components/ui/flow-strip/step.blade.php -
resources/js/ui/flow-strip.js
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: Flow Strip (`flow-strip`)
An ordered, numbered strip of screen images for a user flow: one horizontal, scroll-snapping row joined by connectors, with an image-viewer lightbox per step and, when reorderable, Move earlier and Move later buttons plus a drag handle that also takes arrow keys, hidden inputs in step order and a change event.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add flow-strip
```
## Usage
```blade
{{-- With Livewire: x-on:flow-strip:change="$wire.reorder($event.detail.order)"
and render the steps in the saved order. --}}
<x-ui.flow-strip
reorderable
name="steps"
label="{{ __('Sign-up flow') }}"
:steps="[
['id' => 'sign-up', 'src' => asset('thumbs/block/auth-sign-up.webp'), 'alt' => __('Sign-up form with name, email and password'), 'width' => 960, 'height' => 600, 'title' => __('Sign up')],
['id' => 'verify-email', 'src' => asset('thumbs/block/auth-verify-email.webp'), 'alt' => __('Verify email notice with a resend button'), 'width' => 960, 'height' => 600, 'title' => __('Verify email')],
['id' => 'two-factor', 'src' => asset('thumbs/block/auth-two-factor.webp'), 'alt' => __('Two-factor code entry'), 'width' => 960, 'height' => 600, 'title' => __('Two-factor')],
['id' => 'accept-invite', 'src' => asset('thumbs/block/accept-invite.webp'), 'alt' => __('Team invitation with an accept button'), 'width' => 960, 'height' => 600, 'title' => __('Accept invite')],
['id' => 'sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in')],
]"
/>
```
## Props
- `steps` (array, default `[]`) — Steps in flow order as [['id', 'src', 'original'?, 'alt', 'title'?, 'width'?, 'height'?]]. Rendered through flow-strip.step. Leave empty to render your own <x-ui.flow-strip.step> in the default slot. Each step needs a src and a non-empty alt, or rendering throws.
- `reorderable` (bool, default `false`) — Adds Move earlier and Move later buttons and a drag handle to each step. The controls need JavaScript and stay hidden (x-cloak) until Alpine runs.
- `name` (string|null, default `null`) — When set, each step with an id posts it as a hidden name[] input, in the current order, so a plain form submits the order.
- `label` (string|null, default `null`) — Accessible name of the list, for example "Checkout flow".
- `lightbox` (bool, default `true`) — Each image is an image-viewer trigger that opens the full-size original with zoom. false renders plain images.
- `emptyText` (string|null, default `null`) — Text of the empty state when there are no steps and the slot is empty. Defaults to "No steps yet."
- `id` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `src` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `original` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `alt` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `title` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `width` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `height` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `number` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `total` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
## Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Showing the screens of a user flow in order (sign-up, checkout, onboarding), each numbered and viewable at full size with zoom.
- Letting someone put flow steps in order with the keyboard (Move earlier / Move later buttons or arrow keys on a handle) as well as by drag, and saving the order through a form or Livewire.
- A Livewire view where the server renders the steps in their saved order and each step keeps its own markup across morphs (wire:key per step).
## Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- Showing progress through a process the user is in; use stepper.
- An unordered collection of images; use media-grid.
- Reordering a vertical list of text rows; use sortable.
- Dated events; use timeline.
## Anti-patterns
- Requiring media-heavy interaction when text is faster
## Rules
- Use the `<brok:flow-strip>` tag (or `<x-ui.flow-strip>`) 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/flow-strip
- Registry JSON (files, props, contract): https://brokui.dev/r/open/flow-strip.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.flow-strip reorderable :steps="[]" label="{{ __('Onboarding flow') }}" empty-text="{{ __('This flow has no screens yet. Add a screenshot to start it.') }}" />
Long Content
<div class="max-w-sm">
<x-ui.flow-strip
reorderable
label="{{ __('Password reset flow') }}"
:steps="[
['id' => 'forgot', 'src' => asset('thumbs/block/auth-forgot-password.webp'), 'alt' => __('Forgot password form asking for the account email'), 'width' => 960, 'height' => 600, 'title' => __('Ask for the account email address and explain what happens next')],
['id' => 'reset', 'src' => asset('thumbs/block/auth-reset-password.webp'), 'alt' => __('Reset password form with a new password and its confirmation'), 'width' => 960, 'height' => 600, 'title' => __('Choose a new password')],
['id' => 'sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600],
]"
/>
</div>
Read Only
<x-ui.flow-strip
label="{{ __('Checkout flow') }}"
:steps="[
['id' => 'cart', 'src' => asset('thumbs/block/shopping-cart.webp'), 'alt' => __('Shopping cart with line items and a subtotal'), 'width' => 960, 'height' => 483, 'title' => __('Cart')],
['id' => 'checkout', 'src' => asset('thumbs/block/checkout.webp'), 'alt' => __('Single-column checkout form'), 'width' => 960, 'height' => 720, 'title' => __('Checkout')],
['id' => 'confirmation', 'src' => asset('thumbs/block/order-summary-confirmation.webp'), 'alt' => __('Order confirmation with an itemised receipt'), 'width' => 960, 'height' => 600, 'title' => __('Confirmation')],
['id' => 'tracking', 'src' => asset('thumbs/block/order-summary-tracking.webp'), 'alt' => __('Order tracking summary with a delivery progress bar'), 'width' => 960, 'height' => 462, 'title' => __('Tracking')],
]"
/>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | array | [] | Steps in flow order as [['id', 'src', 'original'?, 'alt', 'title'?, 'width'?, 'height'?]]. Rendered through flow-strip.step. Leave empty to render your own <x-ui.flow-strip.step> in the default slot. Each step needs a src and a non-empty alt, or rendering throws. |
| reorderable | bool | false | Adds Move earlier and Move later buttons and a drag handle to each step. The controls need JavaScript and stay hidden (x-cloak) until Alpine runs. |
| name | string | null | null | When set, each step with an id posts it as a hidden name[] input, in the current order, so a plain form submits the order. |
| label | string | null | null | Accessible name of the list, for example "Checkout flow". |
| lightbox | bool | true | Each image is an image-viewer trigger that opens the full-size original with zoom. false renders plain images. |
| emptyText | string | null | null | Text of the empty state when there are no steps and the slot is empty. Defaults to "No steps yet." |
| id | mixed | null | null | Declared by @props in the registry Blade source. |
| src | mixed | null | null | Declared by @props in the registry Blade source. |
| original | mixed | null | null | Declared by @props in the registry Blade source. |
| alt | mixed | null | null | Declared by @props in the registry Blade source. |
| title | mixed | null | null | Declared by @props in the registry Blade source. |
| width | mixed | null | null | Declared by @props in the registry Blade source. |
| height | mixed | null | null | Declared by @props in the registry Blade source. |
| number | mixed | null | null | Declared by @props in the registry Blade source. |
| total | mixed | null | null | Declared by @props in the registry Blade source. |
Slots
default— Your own <x-ui.flow-strip.step> elements, used instead of steps. A step takes the same keys as a steps entry as props (id, src, original, alt, title, width, height) plus number and total (pass :number="$loop->iteration" :total="count($steps)" so the button names and end states are right before the script runs), reads reorderable, name and lightbox from the strip, and has an actions slot over the image's top-end corner.x-ui.flow-strip.step— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Renders an <ol role="list"> in one horizontal row that scrolls with scroll snap and a thin themed scrollbar; a decorative arrow joins each step to the next and mirrors under dir="rtl".
- The visible step numbers are a CSS counter over the list order, so they follow every move without a re-render.
- Steps are server-rendered, not x-for: the script moves the real <li> nodes, and each step with an id carries wire:key="flow-strip-<id>" so a Livewire re-render in the new order morphs in place.
- Move earlier / Move later move a step one place. At the first or last place the button is aria-disabled but stays focusable; when a move disables the pressed button, focus moves to the other one in the same step.
- The drag handle takes Arrow Left and Right (mirrored under dir="rtl"), Arrow Up and Down, and Home and End. Pointer drag lifts a copy of the step under the pointer while the step turns into a placeholder that moves live to the drop slot; the strip scrolls near its edges. Other steps glide (FLIP, 200ms); reduced motion skips the glides.
- After each move the hidden inputs follow their step, the button names are rewritten with the new number, a polite live region says "<title>, step N of M", and the root dispatches flow-strip:change with { order: [ids] }. Livewire: x-on:flow-strip:change="$wire.reorder($event.detail.order)" and re-render the steps in the saved order.
- Window pointer listeners exist only during a drag; a MutationObserver re-derives button names after a Livewire morph, and destroy() removes both.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.
Guidance
Select, preview, or compare visual media.
Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Showing the screens of a user flow in order (sign-up, checkout, onboarding), each numbered and viewable at full size with zoom.
- Letting someone put flow steps in order with the keyboard (Move earlier / Move later buttons or arrow keys on a handle) as well as by drag, and saving the order through a form or Livewire.
- A Livewire view where the server renders the steps in their saved order and each step keeps its own markup across morphs (wire:key per step).
Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- Showing progress through a process the user is in; use stepper.
- An unordered collection of images; use media-grid.
- Reordering a vertical list of text rows; use sortable.
- Dated events; use timeline.
Use instead
- Text or list representation
Anti-patterns
- Requiring media-heavy interaction when text is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Enter Space ArrowLeft ArrowRight ArrowUp ArrowDown Home End Escape
- Focus
Focus stays on the move button or handle that moved a step; when a move disables the pressed button, focus goes to the other move button of that step.
- The list is an ordered list with an optional accessible name; screen readers announce each step's position from the list itself, and the visible number is aria-hidden.
- Every move control is a named button ("Move step 2, Sign in, earlier"); the handle is described by a hint naming its keys.
- Each move is announced politely as "<title>, step N of M", and focus stays on the control that moved the step.
- Without JavaScript the strip shows the server order with image-viewer's link to the original image, and no reorder controls.
- 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 wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
{{-- With Livewire: x-on:flow-strip:change="$wire.reorder($event.detail.order)"
and render the steps in the saved order. --}}
<x-ui.flow-strip
reorderable
name="steps"
label="{{ __('Sign-up flow') }}"
:steps="[
['id' => 'sign-up', 'src' => asset('thumbs/block/auth-sign-up.webp'), 'alt' => __('Sign-up form with name, email and password'), 'width' => 960, 'height' => 600, 'title' => __('Sign up')],
['id' => 'verify-email', 'src' => asset('thumbs/block/auth-verify-email.webp'), 'alt' => __('Verify email notice with a resend button'), 'width' => 960, 'height' => 600, 'title' => __('Verify email')],
['id' => 'two-factor', 'src' => asset('thumbs/block/auth-two-factor.webp'), 'alt' => __('Two-factor code entry'), 'width' => 960, 'height' => 600, 'title' => __('Two-factor')],
['id' => 'accept-invite', 'src' => asset('thumbs/block/accept-invite.webp'), 'alt' => __('Team invitation with an accept button'), 'width' => 960, 'height' => 600, 'title' => __('Accept invite')],
['id' => 'sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in')],
]"
/>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Steps in flow order: [['id', 'src', 'original'?, 'alt', 'title'?, 'width'?, 'height'?], ...].
// Leave empty and render flow-strip.step components in the default slot when a
// Livewire view draws its own steps (with actions).
'steps' => [],
// Adds Move earlier / Move later buttons and a drag handle per step.
'reorderable' => false,
// When set, each step posts its id as a hidden `name[]` input, in the current order.
'name' => null,
// Accessible name of the list, for example "Checkout flow".
'label' => null,
// Each image opens image-viewer with zoom. false renders plain images.
'lightbox' => true,
// Shown when there are no steps and the slot is empty.
'emptyText' => null,
])
@php
$reorderable = filter_var($reorderable, FILTER_VALIDATE_BOOLEAN);
$lightbox = filter_var($lightbox, FILTER_VALIDATE_BOOLEAN);
$hasSlot = $slot->isNotEmpty();
$steps = array_values(is_array($steps) ? $steps : []);
$total = count($steps);
$isEmpty = ! $hasSlot && $steps === [];
@endphp
<div
data-slot="flow-strip"
data-reorderable="{{ $reorderable ? 'true' : 'false' }}"
@if ($reorderable)
x-data="uiFlowStrip({
messages: {
earlier: @js(__('Move step :number, :title, earlier')),
earlierUntitled: @js(__('Move step :number earlier')),
later: @js(__('Move step :number, :title, later')),
laterUntitled: @js(__('Move step :number later')),
handle: @js(__('Reorder step :number, :title')),
handleUntitled: @js(__('Reorder step :number')),
moved: @js(__(':title, step :number of :total')),
movedUntitled: @js(__('Step :number of :total')),
},
})"
x-id="['flow-strip-hint']"
@endif
{{ $attributes->merge(['class' => 'relative w-full min-w-0 text-foreground']) }}
>
@if ($isEmpty)
<div data-slot="flow-strip-empty">
<x-ui.empty size="sm">
<x-ui.empty.description>{{ filled($emptyText) ? $emptyText : __('No steps yet.') }}</x-ui.empty.description>
</x-ui.empty>
</div>
@else
{{-- Steps sit in one scrollable row. The padding keeps focus rings
inside the scroll box; the counter numbers the steps in DOM
order, so the numbers follow every reorder without a re-render. --}}
<ol
role="list"
data-slot="flow-strip-list"
@if (filled($label)) aria-label="{{ $label }}" @endif
class="flex min-w-0 snap-x snap-mandatory items-start gap-8 overflow-x-auto overscroll-x-contain scroll-px-1 p-1 pb-4 [counter-reset:flow-step] data-[snap=off]:snap-none"
>
@if ($hasSlot)
{{ $slot }}
@else
{{-- reorderable, name and lightbox reach each step through @aware. --}}
@foreach ($steps as $step)
<x-ui.flow-strip.step
:id="$step['id'] ?? null"
:src="$step['src'] ?? null"
:original="$step['original'] ?? null"
:alt="$step['alt'] ?? null"
:title="$step['title'] ?? null"
:width="$step['width'] ?? null"
:height="$step['height'] ?? null"
:number="$loop->iteration"
:total="$total"
/>
@endforeach
@endif
</ol>
@endif
@if ($reorderable)
<p :id="$id('flow-strip-hint')" class="sr-only">{{ __('Arrow keys move this step. Home and End move it to the start or the end.') }}</p>
<p data-slot="flow-strip-live" role="status" aria-live="polite" class="sr-only" x-text="status"></p>
@endif
</div>
@aware([
'reorderable' => false,
'name' => null,
'lightbox' => true,
])
@props([
// Stable step id. Keys the step for Livewire (wire:key="flow-strip-<id>"),
// fills the hidden input and the flow-strip:change order.
'id' => null,
// Screen image. Required.
'src' => null,
// Full-size image for the lightbox. Falls back to src.
'original' => null,
// Required text alternative.
'alt' => null,
// Short step name ("Sign in"). Shown over the image, used in the move
// buttons' names and in the reorder announcement.
'title' => null,
// Intrinsic size, so the strip reserves the space before the image loads.
'width' => null,
'height' => null,
// 1-based position and step count, for the server-rendered button names
// and end states. flow-strip passes both; pass them when you render steps
// yourself (:number="$loop->iteration" :total="count($steps)"). The script
// keeps them current after every move.
'number' => null,
'total' => null,
])
@php
if (blank($src)) {
throw new \InvalidArgumentException('flow-strip steps need a src.');
}
if (blank($alt)) {
throw new \InvalidArgumentException('flow-strip steps need a non-empty alt text.');
}
$reorderable = filter_var($reorderable, FILTER_VALIDATE_BOOLEAN);
$lightbox = filter_var($lightbox, FILTER_VALIDATE_BOOLEAN);
$number = filled($number) ? (int) $number : null;
$total = filled($total) ? (int) $total : null;
$hasActions = isset($actions) && $actions->isNotEmpty();
// Server-rendered names match what the script writes after a move.
$moveLabel = fn (string $direction): string => match (true) {
$direction === 'earlier' && $number !== null && filled($title) => __('Move step :number, :title, earlier', ['number' => $number, 'title' => $title]),
$direction === 'earlier' && $number !== null => __('Move step :number earlier', ['number' => $number]),
$direction === 'earlier' && filled($title) => __('Move :title earlier', ['title' => $title]),
$direction === 'earlier' => __('Move this step earlier'),
$number !== null && filled($title) => __('Move step :number, :title, later', ['number' => $number, 'title' => $title]),
$number !== null => __('Move step :number later', ['number' => $number]),
filled($title) => __('Move :title later', ['title' => $title]),
default => __('Move this step later'),
};
$handleLabel = match (true) {
$number !== null && filled($title) => __('Reorder step :number, :title', ['number' => $number, 'title' => $title]),
$number !== null => __('Reorder step :number', ['number' => $number]),
filled($title) => __('Reorder :title', ['title' => $title]),
default => __('Reorder this step'),
};
$atStart = $number === 1;
$atEnd = $number !== null && $total !== null && $number >= $total;
$stepAttributes = filled($id) ? ['wire:key' => 'flow-strip-'.$id, 'data-id' => $id] : [];
if (filled($title)) {
$stepAttributes['data-title'] = $title;
}
@endphp
<li
data-slot="flow-strip-step"
{{ $attributes->merge($stepAttributes + ['class' => 'group/flow-step relative flex w-48 shrink-0 snap-start flex-col gap-2 [counter-increment:flow-step] data-[placeholder=true]:rounded-md data-[placeholder=true]:bg-muted data-[placeholder=true]:outline-2 data-[placeholder=true]:outline-dashed data-[placeholder=true]:outline-border data-[placeholder=true]:[&>*]:invisible data-[ghost=true]:pointer-events-none data-[ghost=true]:fixed data-[ghost=true]:z-overlay data-[ghost=true]:m-0 data-[ghost=true]:rounded-md data-[ghost=true]:bg-card data-[ghost=true]:shadow-xl data-[ghost=true]:ring-1 data-[ghost=true]:ring-border']) }}
>
<div data-slot="flow-strip-step-header" class="flex min-w-0 items-start gap-2">
{{-- The visible number is a CSS counter over the list order, so it
follows every move; the list itself gives assistive tech the position. --}}
<span data-slot="flow-strip-number" aria-hidden="true" class="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium tabular-nums text-muted-foreground before:content-[counter(flow-step)]"></span>
@if (filled($title))
<p data-slot="flow-strip-title" class="line-clamp-2 min-w-0 break-words pt-0.5 text-sm font-medium text-foreground">{{ $title }}</p>
@endif
</div>
<div data-slot="flow-strip-media" class="relative min-w-0">
@if ($lightbox)
<x-ui.image-viewer
fill
:src="$src"
:original="$original"
:alt="$alt"
:title="$title"
:width="$width"
:height="$height"
>
<x-slot:trigger>
<img
src="{{ $src }}"
alt="{{ $alt }}"
@if (filled($width)) width="{{ (int) $width }}" @endif
@if (filled($height)) height="{{ (int) $height }}" @endif
loading="lazy"
decoding="async"
draggable="false"
data-slot="flow-strip-image"
class="block h-auto max-h-80 w-full bg-muted object-cover object-top"
/>
</x-slot:trigger>
</x-ui.image-viewer>
@else
<div class="overflow-hidden rounded-md bg-muted">
<img
src="{{ $src }}"
alt="{{ $alt }}"
@if (filled($width)) width="{{ (int) $width }}" @endif
@if (filled($height)) height="{{ (int) $height }}" @endif
loading="lazy"
decoding="async"
draggable="false"
data-slot="flow-strip-image"
class="block h-auto max-h-80 w-full object-cover object-top"
/>
</div>
@endif
<span aria-hidden="true" class="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-border"></span>
@if ($hasActions)
<div data-slot="flow-strip-step-actions" class="absolute end-2 top-2 flex items-center gap-1">
{{ $actions }}
</div>
@endif
{{-- Decorative connector to the next step; hidden after the last one
and mirrored under dir="rtl". --}}
<span data-slot="flow-strip-connector" aria-hidden="true" class="pointer-events-none absolute start-full top-1/2 flex w-8 -translate-y-1/2 justify-center text-muted-foreground group-last/flow-step:hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:-scale-x-100"><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>
</span>
</div>
@if ($reorderable)
{{-- Reorder controls need the script: x-cloak keeps them hidden until
Alpine runs, so a page without JavaScript shows no dead buttons. --}}
<div data-slot="flow-strip-controls" x-cloak class="flex items-center justify-center gap-1">
<x-ui.button
variant="ghost"
size="sm"
:icon-label="$moveLabel('earlier')"
aria-label="{{ $moveLabel('earlier') }}"
aria-disabled="{{ $atStart ? 'true' : 'false' }}"
data-action="move-earlier"
x-on:click="moveBy($event.currentTarget, -1)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:-scale-x-100" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg>
</x-ui.button>
<x-ui.button
variant="ghost"
size="sm"
:icon-label="$handleLabel"
aria-label="{{ $handleLabel }}"
x-bind:aria-describedby="$id('flow-strip-hint')"
data-action="drag"
class="cursor-grab touch-none active:cursor-grabbing"
x-on:pointerdown="startDrag($event)"
x-on:keydown="onHandleKey($event)"
>
<svg viewBox="0 0 24 24" fill="currentColor" class="size-4" aria-hidden="true"><circle cx="6" cy="9" r="1.5" /><circle cx="12" cy="9" r="1.5" /><circle cx="18" cy="9" r="1.5" /><circle cx="6" cy="15" r="1.5" /><circle cx="12" cy="15" r="1.5" /><circle cx="18" cy="15" r="1.5" /></svg>
</x-ui.button>
<x-ui.button
variant="ghost"
size="sm"
:icon-label="$moveLabel('later')"
aria-label="{{ $moveLabel('later') }}"
aria-disabled="{{ $atEnd ? 'true' : 'false' }}"
data-action="move-later"
x-on:click="moveBy($event.currentTarget, 1)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 rtl:-scale-x-100" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</x-ui.button>
</div>
@endif
@if (filled($name) && filled($id))
<input type="hidden" name="{{ $name }}[]" value="{{ $id }}" data-slot="flow-strip-input" />
@endif
</li>
/**
* Flow strip reordering, for a reorderable <x-ui.flow-strip>.
*
* The steps are server-rendered <li> elements (keyed with wire:key), so this
* script moves the real nodes instead of re-rendering a list: Livewire can
* morph the strip afterwards and every step keeps its own markup.
*
* Buttons: Move earlier / Move later move a step one place. At the ends they
* are aria-disabled (still focusable); when a move disables the pressed
* button, focus goes to the other one in the same step.
* Handle: Arrow Left / Right (mirrored under dir="rtl"), Arrow Up / Down and
* Home / End move the step; pointer drag lifts a copy of the step under the
* pointer while the step itself becomes a placeholder that moves live to the
* slot the drop would land in. Other steps glide (FLIP); reduced motion
* skips every glide.
*
* After each move the CSS counter renumbers the steps, the hidden name[]
* inputs follow their step, the button names are rewritten, a polite live
* region says "<title>, step N of M" and the root dispatches
* `flow-strip:change` with { order: [ids] }.
*
* Self-registers on `alpine:init` so import order does not matter.
*/
document.addEventListener('alpine:init', () => {
const STEP = '[data-slot="flow-strip-step"]';
const GLIDE = { duration: 200, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
window.Alpine.data('uiFlowStrip', (config = {}) => ({
status: '',
messages: config.messages ?? {},
root: null,
list: null,
drag: null,
_reduced: false,
_observer: null,
_queued: false,
_move: null,
_up: null,
init() {
this.root = this.$el;
this.list = this.root.querySelector('[data-slot="flow-strip-list"]');
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this._move = (e) => this.onMove(e);
this._up = () => this.onUp();
this.refresh();
// Livewire may morph steps in or out, or reset the names this
// script wrote; re-derive them whenever the list changes.
if (this.list && 'MutationObserver' in window) {
this._observer = new MutationObserver(() => this.queueRefresh());
this._observer.observe(this.list, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-label', 'aria-disabled'] });
}
},
destroy() {
this.stopListening();
this._observer?.disconnect();
this.drag?.ghost?.remove();
this.drag = null;
},
// ---- Steps, names and end states -----------------------------------
steps() {
return this.list ? [...this.list.children].filter((el) => el.matches(STEP)) : [];
},
stepOf(el) {
return el?.closest(STEP) ?? null;
},
format(key, values) {
const template = this.messages[key] ?? '';
return template.replace(/:(\w+)/g, (match, name) => (name in values ? String(values[name]) : match));
},
label(kind, step, number) {
const title = step.dataset.title;
return this.format(title ? kind : `${kind}Untitled`, { number, title });
},
setAttr(el, name, value) {
if (el && el.getAttribute(name) !== value) el.setAttribute(name, value);
},
refresh() {
const steps = this.steps();
steps.forEach((step, i) => {
const earlier = step.querySelector('[data-action="move-earlier"]');
const later = step.querySelector('[data-action="move-later"]');
const handle = step.querySelector('[data-action="drag"]');
this.setAttr(earlier, 'aria-label', this.label('earlier', step, i + 1));
this.setAttr(later, 'aria-label', this.label('later', step, i + 1));
this.setAttr(handle, 'aria-label', this.label('handle', step, i + 1));
this.setAttr(earlier, 'aria-disabled', i === 0 ? 'true' : 'false');
this.setAttr(later, 'aria-disabled', i === steps.length - 1 ? 'true' : 'false');
});
},
queueRefresh() {
if (this._queued) return;
this._queued = true;
queueMicrotask(() => {
this._queued = false;
this.refresh();
});
},
rects() {
const rects = new Map();
for (const step of this.steps()) rects.set(step, step.getBoundingClientRect());
return rects;
},
glide(before) {
if (this._reduced) return;
for (const step of this.steps()) {
const prev = before.get(step);
if (!prev || step.dataset.placeholder === 'true') continue;
const now = step.getBoundingClientRect();
const dx = prev.left - now.left;
if (!dx) continue;
step.getAnimations().forEach((a) => a.cancel());
step.animate([{ transform: `translateX(${dx}px)` }, { transform: 'none' }], GLIDE);
}
},
// Scroll snapping re-snaps to the moved step after every DOM change,
// which would scroll the strip under a drag and skew the glide
// measurements. It is off while a step moves and comes back after.
pauseSnap() {
this.list.dataset.snap = 'off';
},
resumeSnap() {
if (this.drag) return;
requestAnimationFrame(() => {
if (!this.drag) delete this.list.dataset.snap;
});
},
// Moves the step node to index `to`. Returns false when nothing moved.
place(step, to) {
const steps = this.steps();
const from = steps.indexOf(step);
const dest = Math.max(0, Math.min(to, steps.length - 1));
if (from < 0 || dest === from) return false;
this.pauseSnap();
const before = this.rects();
window.Alpine.mutateDom(() => {
if (dest > from) steps[dest].after(step);
else steps[dest].before(step);
});
this.refresh();
this.glide(before);
return true;
},
announce(step) {
const steps = this.steps();
const number = steps.indexOf(step) + 1;
const title = step.dataset.title;
this.status = this.format(title ? 'moved' : 'movedUntitled', { number, total: steps.length, title });
},
emit() {
const order = this.steps().map((step, i) => step.dataset.id ?? String(i));
this.root.dispatchEvent(new CustomEvent('flow-strip:change', { detail: { order }, bubbles: true }));
},
commit(step) {
this.announce(step);
this.emit();
},
// ---- Buttons and keys ----------------------------------------------
moveBy(button, delta) {
if (button.getAttribute('aria-disabled') === 'true') return;
const step = this.stepOf(button);
if (!step || !this.place(step, this.steps().indexOf(step) + delta)) return;
this.commit(step);
// A moved node loses focus; keep it on the pressed button unless
// that button is now at an end, then hand it to its sibling.
const sibling = step.querySelector(`[data-action="${delta < 0 ? 'move-later' : 'move-earlier'}"]`);
const target = button.getAttribute('aria-disabled') === 'true' ? sibling : button;
target?.focus();
this.resumeSnap();
},
onHandleKey(e) {
const handle = e.currentTarget;
const step = this.stepOf(handle);
if (!step || this.drag) return;
const steps = this.steps();
const i = steps.indexOf(step);
const rtl = getComputedStyle(this.root).direction === 'rtl';
const map = {
ArrowUp: i - 1,
ArrowDown: i + 1,
ArrowLeft: rtl ? i + 1 : i - 1,
ArrowRight: rtl ? i - 1 : i + 1,
Home: 0,
End: steps.length - 1,
};
if (!(e.key in map)) return;
e.preventDefault();
if (this.place(step, map[e.key])) this.commit(step);
handle.focus();
this.resumeSnap();
},
// ---- Pointer drag --------------------------------------------------
startDrag(e) {
if (e.button !== undefined && e.button !== 0) return;
const handle = e.currentTarget;
const step = this.stepOf(handle);
if (!step || this.drag) return;
e.preventDefault();
const r = step.getBoundingClientRect();
const ghost = this.makeGhost(step, r);
this.drag = { step, handle, ghost, origin: this.steps().indexOf(step), dx: e.clientX - r.left, dy: e.clientY - r.top, x: r.left, y: r.top };
step.dataset.placeholder = 'true';
this.pauseSnap();
handle.focus({ preventScroll: true });
window.addEventListener('pointermove', this._move);
window.addEventListener('pointerup', this._up);
window.addEventListener('pointercancel', this._up);
},
// A visual copy of the step that follows the pointer. x-ignore keeps
// Alpine off it; ids, keys and form fields are stripped so the copy
// never duplicates the real step for scripts, Livewire or a form.
makeGhost(step, r) {
const ghost = step.cloneNode(true);
ghost.setAttribute('x-ignore', '');
ghost.setAttribute('aria-hidden', 'true');
ghost.setAttribute('inert', '');
ghost.removeAttribute('wire:key');
ghost.removeAttribute('data-id');
ghost.dataset.slot = 'flow-strip-ghost';
ghost.dataset.ghost = 'true';
ghost.querySelectorAll('input, noscript, template').forEach((el) => el.remove());
ghost.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
Object.assign(ghost.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px` });
// On the body: a fixed copy inside an ancestor with a transform,
// filter or containment (a sheet, a scaled preview) would be
// positioned against that ancestor instead of the viewport.
window.Alpine.mutateDom(() => document.body.appendChild(ghost));
return ghost;
},
onMove(e) {
const drag = this.drag;
if (!drag) return;
drag.x = e.clientX - drag.dx;
drag.y = e.clientY - drag.dy;
drag.ghost.style.left = `${drag.x}px`;
drag.ghost.style.top = `${drag.y}px`;
// Near an edge of the strip, scroll it so far steps stay reachable.
const box = this.list.getBoundingClientRect();
if (e.clientX < box.left + 48) this.list.scrollLeft -= 16;
else if (e.clientX > box.right - 48) this.list.scrollLeft += 16;
// The slot is decided by step midpoints along the reading direction.
const rtl = getComputedStyle(this.root).direction === 'rtl';
const steps = this.steps();
const current = steps.indexOf(drag.step);
let target = steps.length - 1;
for (const [idx, step] of steps.entries()) {
if (step === drag.step) continue;
const r = step.getBoundingClientRect();
const mid = r.left + r.width / 2;
if (rtl ? e.clientX > mid : e.clientX < mid) {
target = idx > current ? idx - 1 : idx;
break;
}
}
if (target !== current) this.place(drag.step, target);
},
onUp() {
const drag = this.drag;
if (!drag) return;
this.stopListening();
const moved = this.steps().indexOf(drag.step) !== drag.origin;
const finish = () => {
drag.ghost.remove();
delete drag.step.dataset.placeholder;
this.drag = null;
this.resumeSnap();
if (moved) this.commit(drag.step);
};
if (this._reduced) {
finish();
return;
}
const to = drag.step.getBoundingClientRect();
const anim = drag.ghost.animate(
[{ left: `${drag.x}px`, top: `${drag.y}px` }, { left: `${to.left}px`, top: `${to.top}px` }],
{ duration: 180, easing: GLIDE.easing, fill: 'forwards' },
);
anim.onfinish = finish;
anim.oncancel = finish;
},
stopListening() {
window.removeEventListener('pointermove', this._move);
window.removeEventListener('pointerup', this._up);
window.removeEventListener('pointercancel', this._up);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.0 - 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