Skip to content
Brok UI

Loading…

No results

Media Collections

Open source

Data-driven image sets with a zoom lightbox — a masonry or uniform tile grid, and an ordered, reorderable strip of screen-flow steps.

Version
v1.0.0
Stability
stable
License
MIT
Related
Sortable
Stepper
Media Grid
Image Viewer
Timeline

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

  1. Sign up

  2. Verify email

  3. Two-factor

  4. Accept invite

  5. Sign in

Arrow keys move this step. Home and End move it to the start or the end.

previews.components.flow-strip.default.blade.php 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')],
    ]"
/>

Installation

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

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

  • blade resources/views/components/ui/flow-strip.blade.php
  • blade resources/views/components/ui/flow-strip/step.blade.php
  • js resources/js/ui/flow-strip.js
Registry dependencies
image-viewer button empty
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.

flow-strip.md
# 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

empty.blade.php Blade
<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.blade.php Blade
<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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

flow-strip flow-strip-connector flow-strip-controls flow-strip-empty flow-strip-image flow-strip-input flow-strip-list flow-strip-live flow-strip-media flow-strip-number flow-strip-step flow-strip-step-actions flow-strip-step-header flow-strip-title

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

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.
  • 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
flow-strip flow-strip-list flow-strip-step flow-strip-step-header flow-strip-number flow-strip-title flow-strip-media flow-strip-image flow-strip-step-actions flow-strip-connector flow-strip-controls flow-strip-input flow-strip-live flow-strip-empty
Theming hooks
flow-strip flow-strip-step image-viewer button

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    {{-- 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.

resources/views/components/ui/flow-strip.blade.php Blade
@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>
resources/views/components/ui/flow-strip/step.blade.php Blade
@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>
resources/js/ui/flow-strip.js JS
/**
 * 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