Skip to content
Brok UI

Loading…

No results

Split View

Open source

The list + detail layout of a triage (inbox) screen: list and detail panes side by side with a resizable divider, one pane at a time with a Back button in a narrow container, and focus returned to the row that opened the detail.

Version
v1.1.1
Stability
stable
License
MIT
Related
Resizable
Sidebar
Sheet
Issue List

Preview

Inbox

3 messages

Quarterly report draft

From Ada Lovelace at 09:12

The figures for the third quarter are in. Can you check the totals?

Deploy window moved

From Grace Hopper at 08:40

We moved the release to Thursday afternoon.

Invoice 2041 paid

From Alan Turing at Yesterday

Payment arrived this morning. No action needed.

Select an item to see its details.

previews.components.split-view.default.blade.php Blade
{{-- Give the root a height. Each row is a real link with data-split-view-open:
     with JavaScript it opens the detail pane (and, in a narrow container, slides
     it in with a Back button); without it the link still navigates. --}}
@php
    $messages = [
        ['id' => 1, 'from' => 'Ada Lovelace', 'subject' => __('Quarterly report draft'), 'preview' => __('The figures for the third quarter are in. Can you check the totals?'), 'time' => __('09:12')],
        ['id' => 2, 'from' => 'Grace Hopper', 'subject' => __('Deploy window moved'), 'preview' => __('We moved the release to Thursday afternoon.'), 'time' => __('08:40')],
        ['id' => 3, 'from' => 'Alan Turing', 'subject' => __('Invoice 2041 paid'), 'preview' => __('Payment arrived this morning. No action needed.'), 'time' => __('Yesterday')],
    ];
@endphp
<div x-data="{ selected: null }" class="w-full">
    <x-ui.split-view class="h-96 w-full overflow-hidden rounded-lg border border-border" persist="split-view-demo">
        <x-slot:header class="flex items-center justify-between gap-2 px-4 py-2">
            <h2 class="text-sm font-semibold">{{ __('Inbox') }}</h2>
            <span class="text-sm text-muted-foreground">{{ __('3 messages') }}</span>
        </x-slot:header>

        <x-slot:list>
            <ul role="list" class="divide-y divide-border">
                @foreach ($messages as $message)
                    <li>
                        <a
                            href="#message-{{ $message['id'] }}"
                            data-split-view-open
                            x-on:click.prevent="selected = {{ $message['id'] }}"
                            :aria-current="selected === {{ $message['id'] }} ? 'true' : null"
                            class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=true]:bg-muted"
                        >
                            <span class="flex min-w-0 items-baseline justify-between gap-2">
                                <span class="truncate text-sm font-medium">{{ $message['from'] }}</span>
                                <span class="shrink-0 text-xs text-muted-foreground">{{ $message['time'] }}</span>
                            </span>
                            <span class="truncate text-sm">{{ $message['subject'] }}</span>
                            <span class="truncate text-xs text-muted-foreground">{{ $message['preview'] }}</span>
                        </a>
                    </li>
                @endforeach
            </ul>
        </x-slot:list>

        <x-slot:detail>
            @foreach ($messages as $message)
                <article id="message-{{ $message['id'] }}" x-show="selected === {{ $message['id'] }}" class="flex flex-col gap-2 p-6">
                    <h3 class="text-sm font-semibold">{{ $message['subject'] }}</h3>
                    <p class="text-xs text-muted-foreground">{{ __('From :name at :time', ['name' => $message['from'], 'time' => $message['time']]) }}</p>
                    <p class="text-sm">{{ $message['preview'] }}</p>
                </article>
            @endforeach
        </x-slot:detail>
    </x-ui.split-view>
</div>

Installation

terminal
php artisan ui:add split-view

Note

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

resources/js/ui/index.js JS
import './split-view.js';

Registry contract

php artisan ui:add split-view 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/split-view.blade.php
  • js resources/js/ui/split-view.js
Registry dependencies
resizable button
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.

split-view.md
# Brok UI: Split View (`split-view`)

The list + detail layout of a triage (inbox) screen: list and detail panes side by side with a resizable divider, one pane at a time with a Back button in a narrow container, and focus returned to the row that opened the detail.

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

## Install

```bash
php artisan ui:add split-view
```

## Usage

```blade
{{-- Give the root a height. Each row is a real link with data-split-view-open:
     with JavaScript it opens the detail pane (and, in a narrow container, slides
     it in with a Back button); without it the link still navigates. --}}
@php
    $messages = [
        ['id' => 1, 'from' => 'Ada Lovelace', 'subject' => __('Quarterly report draft'), 'preview' => __('The figures for the third quarter are in. Can you check the totals?'), 'time' => __('09:12')],
        ['id' => 2, 'from' => 'Grace Hopper', 'subject' => __('Deploy window moved'), 'preview' => __('We moved the release to Thursday afternoon.'), 'time' => __('08:40')],
        ['id' => 3, 'from' => 'Alan Turing', 'subject' => __('Invoice 2041 paid'), 'preview' => __('Payment arrived this morning. No action needed.'), 'time' => __('Yesterday')],
    ];
@endphp
<div x-data="{ selected: null }" class="w-full">
    <x-ui.split-view class="h-96 w-full overflow-hidden rounded-lg border border-border" persist="split-view-demo">
        <x-slot:header class="flex items-center justify-between gap-2 px-4 py-2">
            <h2 class="text-sm font-semibold">{{ __('Inbox') }}</h2>
            <span class="text-sm text-muted-foreground">{{ __('3 messages') }}</span>
        </x-slot:header>

        <x-slot:list>
            <ul role="list" class="divide-y divide-border">
                @foreach ($messages as $message)
                    <li>
                        <a
                            href="#message-{{ $message['id'] }}"
                            data-split-view-open
                            x-on:click.prevent="selected = {{ $message['id'] }}"
                            :aria-current="selected === {{ $message['id'] }} ? 'true' : null"
                            class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=true]:bg-muted"
                        >
                            <span class="flex min-w-0 items-baseline justify-between gap-2">
                                <span class="truncate text-sm font-medium">{{ $message['from'] }}</span>
                                <span class="shrink-0 text-xs text-muted-foreground">{{ $message['time'] }}</span>
                            </span>
                            <span class="truncate text-sm">{{ $message['subject'] }}</span>
                            <span class="truncate text-xs text-muted-foreground">{{ $message['preview'] }}</span>
                        </a>
                    </li>
                @endforeach
            </ul>
        </x-slot:list>

        <x-slot:detail>
            @foreach ($messages as $message)
                <article id="message-{{ $message['id'] }}" x-show="selected === {{ $message['id'] }}" class="flex flex-col gap-2 p-6">
                    <h3 class="text-sm font-semibold">{{ $message['subject'] }}</h3>
                    <p class="text-xs text-muted-foreground">{{ __('From :name at :time', ['name' => $message['from'], 'time' => $message['time']]) }}</p>
                    <p class="text-sm">{{ $message['preview'] }}</p>
                </article>
            @endforeach
        </x-slot:detail>
    </x-ui.split-view>
</div>
```

## Props

- `detailOpen` (bool, default `false`) — Server-rendered open state. In a narrow container it shows the detail pane instead of the list; side by side (detailMode toggle) it shows the detail slot instead of the empty state. x-model or wire:model on the root binds the same state (x-modelable onto the namespaced splitViewOpen, so the bound name, such as detailOpen, stays the consumer's own variable).
- `detailMode` (toggle|on-demand, default `toggle`) — toggle: the open state shows or hides the detail in both layouts. on-demand: side by side the detail slot always shows (the empty state only when the slot is empty), so the selected record stays visible; stacked, the list and header show first and the detail pane appears only once a row, the model or an event opens it. An unknown value falls back to toggle.
- `listSize` (float, default `36`) — The list pane's initial share of the width in percent, clamped to minListSize and maxListSize.
- `minListSize` (float, default `24`) — The narrowest the list pane can be resized to, in percent.
- `maxListSize` (float, default `56`) — The widest the list pane can be resized to, in percent.
- `persist` (string|null, default `null`) — localStorage key that remembers the resized list width (passed to resizable); null keeps it for the page only.
- `backLabel` (string|null, default `null`) — Visible text and accessible name of the Back button shown in a narrow container; null uses "Back".
- `listLabel` (string|null, default `null`) — Accessible name of the list region; null uses "List".
- `detailLabel` (string|null, default `null`) — Accessible name of the detail region; null uses "Detail".
- `resizeLabel` (string|null, default `null`) — Accessible name of the divider; null uses "Resize list".
- `emptyText` (string|null, default `null`) — Text in the detail pane while nothing is open; null uses "Select an item to see its details." The empty slot replaces it.

## Use when

- Use for secondary or optional content, especially when users usually inspect one short section at a time.
- Building a triage screen (inbox, queue, review list) where picking a row shows its detail beside the list.
- Needing the list/detail pair to collapse to one pane with a Back control on narrow screens and inside narrow app shells.

## Avoid when

- Do not hide required workflow content or comparison-heavy information because disclosure controls reduce visibility and increase interaction cost.
- The two panels are peers with no list/detail relationship (code and preview, editor and inspector); use resizable.
- The detail should cover the page as an overlay without leaving the list context; use sheet or drawer.
- The page shows one record with no list beside it; use a detail page layout.

## Anti-patterns

- Hiding required workflow content
- Using accordions for cross-section comparison

## Rules

- Use the `<brok:split-view>` tag (or `<x-ui.split-view>`) 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/split-view
- Registry JSON (files, props, contract): https://brokui.dev/r/open/split-view.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

detail-open.blade.php Blade
{{-- Render the detail open from the server (a /inbox/{id} route) with
     detail-open. Mark the open row with aria-current so Back returns focus to it. --}}
<x-ui.split-view id="split-view-detail-open" class="h-96 w-full overflow-hidden rounded-lg border border-border" detail-open>
    <x-slot:list>
        <ul role="list" class="divide-y divide-border">
            <li>
                <a href="#message-1" data-split-view-open aria-current="true" class="flex min-h-11 min-w-0 flex-col gap-1 bg-muted px-4 py-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                    <span class="truncate text-sm font-medium">Ada Lovelace</span>
                    <span class="truncate text-sm">{{ __('Quarterly report draft') }}</span>
                </a>
            </li>
            <li>
                <a href="#message-2" data-split-view-open class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                    <span class="truncate text-sm font-medium">Grace Hopper</span>
                    <span class="truncate text-sm">{{ __('Deploy window moved') }}</span>
                </a>
            </li>
        </ul>
    </x-slot:list>

    <x-slot:detail>
        <article id="message-1" class="flex flex-col gap-2 p-6">
            <h3 class="text-sm font-semibold">{{ __('Quarterly report draft') }}</h3>
            <p class="text-xs text-muted-foreground">{{ __('From Ada Lovelace at 09:12') }}</p>
            <p class="text-sm">{{ __('The figures for the third quarter are in. Can you check the totals before Friday?') }}</p>
            <label class="flex flex-col gap-1 text-sm">
                <span>{{ __('Reply') }}</span>
                <textarea rows="3" class="min-h-11 rounded-md border border-input bg-background px-3 py-2 text-sm"></textarea>
            </label>
        </article>
    </x-slot:detail>
</x-ui.split-view>
empty-detail.blade.php Blade
{{-- With nothing open, the detail pane shows the empty text (or an `empty` slot). --}}
<x-ui.split-view id="split-view-empty-detail" class="h-96 w-full overflow-hidden rounded-lg border border-border" :empty-text="__('Choose a conversation to read it here.')">
    <x-slot:list>
        <ul role="list" class="divide-y divide-border">
            <li>
                <a href="#thread-1" data-split-view-open class="flex min-h-11 min-w-0 items-center px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                    <span class="truncate">{{ __('Supplier onboarding') }}</span>
                </a>
            </li>
            <li>
                <a href="#thread-2" data-split-view-open class="flex min-h-11 min-w-0 items-center px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                    <span class="truncate">{{ __('Office move checklist') }}</span>
                </a>
            </li>
        </ul>
    </x-slot:list>
</x-ui.split-view>
long-content.blade.php Blade
{{-- Long translated labels, unbroken strings and a long list: rows truncate,
     the detail wraps, and each pane scrolls on its own. --}}
<x-ui.split-view
    class="h-96 w-full overflow-hidden rounded-lg border border-border"
    detail-open
    :back-label="__('Back to every conversation in this shared mailbox')"
    :list-label="__('Conversations in the shared customer support mailbox')"
>
    <x-slot:header class="px-4 py-2">
        <h2 class="truncate text-sm font-semibold">{{ __('A deliberately long mailbox name that must truncate instead of pushing the layout wider than the screen') }}</h2>
    </x-slot:header>

    <x-slot:list>
        <ul role="list" class="divide-y divide-border">
            @foreach (range(1, 24) as $row)
                <li>
                    <a href="#long-{{ $row }}" data-split-view-open @if ($row === 1) aria-current="true" @endif class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=true]:bg-muted">
                        <span class="truncate text-sm font-medium">{{ __('Customer :n with a very long organisation name', ['n' => $row]) }}</span>
                        <span class="truncate text-sm text-muted-foreground">{{ __('Re: https://example.com/a/very/long/unbroken/path/that/keeps/going/and/going/without/any/spaces') }}</span>
                    </a>
                </li>
            @endforeach
        </ul>
    </x-slot:list>

    <x-slot:detail>
        <article id="long-1" class="flex flex-col gap-2 p-6">
            <h3 class="text-sm font-semibold">{{ __('Customer 1 with a very long organisation name asks about the renewal terms that changed last quarter') }}</h3>
            <p class="text-sm">https://example.com/a/very/long/unbroken/path/that/keeps/going/and/going/without/any/spaces/and/must/wrap/inside/the/detail/pane</p>
            @foreach (range(1, 12) as $paragraph)
                <p class="text-sm">{{ __('Paragraph :n of a long message. The detail pane scrolls on its own while the list keeps its place.', ['n' => $paragraph]) }}</p>
            @endforeach
        </article>
    </x-slot:detail>
</x-ui.split-view>
narrow.blade.php Blade
{{-- The layout follows the space the split view gets, not the viewport: in a
     container under 48rem (a phone, or a column beside an app sidebar) one pane
     shows at a time. Open a row, then use Back or Escape to return to it. --}}
<div class="w-full max-w-sm">
    <x-ui.split-view id="split-view-narrow" class="h-96 w-full overflow-hidden rounded-lg border border-border">
        <x-slot:header class="px-4 py-2">
            <h2 class="text-sm font-semibold">{{ __('Review queue') }}</h2>
        </x-slot:header>

        <x-slot:list>
            <ul role="list" class="divide-y divide-border">
                <li>
                    <a href="#review-1" data-split-view-open class="flex min-h-11 min-w-0 items-center px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                        <span class="truncate">{{ __('Expense claim from the Berlin trip') }}</span>
                    </a>
                </li>
                <li>
                    <a href="#review-2" data-split-view-open class="flex min-h-11 min-w-0 items-center px-4 py-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
                        <span class="truncate">{{ __('New supplier contract') }}</span>
                    </a>
                </li>
            </ul>
        </x-slot:list>

        <x-slot:detail>
            <article id="review-1" class="flex flex-col gap-2 p-6">
                <h3 class="text-sm font-semibold">{{ __('Expense claim from the Berlin trip') }}</h3>
                <p class="text-sm text-muted-foreground">{{ __('Three receipts, total 412 euros. Approve or send it back with a note.') }}</p>
            </article>
        </x-slot:detail>
    </x-ui.split-view>
</div>
on-demand.blade.php Blade
{{-- detail-mode="on-demand": side by side the selected record always shows in
     the detail pane; in a narrow container the list (and header) show first and
     the detail slides in only when a row is opened. The open state is bound
     with x-model to the page's own `detailOpen`: the split view keeps its
     state under a namespaced name, so `detailOpen` inside the slots is the
     page's variable, not the component's. --}}
@php
    $messages = [
        ['id' => 1, 'from' => 'Ada Lovelace', 'subject' => __('Quarterly report draft'), 'preview' => __('The figures for the third quarter are in. Can you check the totals?'), 'time' => __('09:12')],
        ['id' => 2, 'from' => 'Grace Hopper', 'subject' => __('Deploy window moved'), 'preview' => __('We moved the release to Thursday afternoon.'), 'time' => __('08:40')],
        ['id' => 3, 'from' => 'Alan Turing', 'subject' => __('Invoice 2041 paid'), 'preview' => __('Payment arrived this morning. No action needed.'), 'time' => __('Yesterday')],
    ];
@endphp
<div x-data="{ selected: 1, detailOpen: false }" class="w-full">
    <x-ui.split-view
        id="on-demand-inbox"
        detail-mode="on-demand"
        x-model="detailOpen"
        class="h-96 w-full overflow-hidden rounded-lg border border-border"
        back-label="{{ __('Inbox') }}"
    >
        <x-slot:header class="flex items-center justify-between gap-2 px-4 py-2">
            <h2 class="text-sm font-semibold">{{ __('Inbox') }}</h2>
            <span class="text-sm text-muted-foreground">{{ __('3 messages') }}</span>
        </x-slot:header>

        <x-slot:list>
            <ul role="list" class="divide-y divide-border">
                @foreach ($messages as $message)
                    <li>
                        <a
                            href="#on-demand-message-{{ $message['id'] }}"
                            data-split-view-open
                            x-on:click.prevent="selected = {{ $message['id'] }}"
                            :aria-current="selected === {{ $message['id'] }} ? 'true' : null"
                            class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=true]:bg-muted"
                        >
                            <span class="flex min-w-0 items-baseline justify-between gap-2">
                                <span class="truncate text-sm font-medium">{{ $message['from'] }}</span>
                                <span class="shrink-0 text-xs text-muted-foreground">{{ $message['time'] }}</span>
                            </span>
                            <span class="truncate text-sm">{{ $message['subject'] }}</span>
                        </a>
                    </li>
                @endforeach
            </ul>
        </x-slot:list>

        <x-slot:detail>
            @foreach ($messages as $message)
                <article id="on-demand-message-{{ $message['id'] }}" x-show="selected === {{ $message['id'] }}" class="flex flex-col gap-2 p-6">
                    <h3 class="text-sm font-semibold">{{ $message['subject'] }}</h3>
                    <p class="text-xs text-muted-foreground">{{ __('From :name at :time', ['name' => $message['from'], 'time' => $message['time']]) }}</p>
                    <p class="text-sm">{{ $message['preview'] }}</p>
                </article>
            @endforeach
        </x-slot:detail>
    </x-ui.split-view>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
detailOpen bool false Server-rendered open state. In a narrow container it shows the detail pane instead of the list; side by side (detailMode toggle) it shows the detail slot instead of the empty state. x-model or wire:model on the root binds the same state (x-modelable onto the namespaced splitViewOpen, so the bound name, such as detailOpen, stays the consumer's own variable).
detailMode toggle | on-demand toggle toggle: the open state shows or hides the detail in both layouts. on-demand: side by side the detail slot always shows (the empty state only when the slot is empty), so the selected record stays visible; stacked, the list and header show first and the detail pane appears only once a row, the model or an event opens it. An unknown value falls back to toggle.
listSize float 36 The list pane's initial share of the width in percent, clamped to minListSize and maxListSize.
minListSize float 24 The narrowest the list pane can be resized to, in percent.
maxListSize float 56 The widest the list pane can be resized to, in percent.
persist string | null null localStorage key that remembers the resized list width (passed to resizable); null keeps it for the page only.
backLabel string | null null Visible text and accessible name of the Back button shown in a narrow container; null uses "Back".
listLabel string | null null Accessible name of the list region; null uses "List".
detailLabel string | null null Accessible name of the detail region; null uses "Detail".
resizeLabel string | null null Accessible name of the divider; null uses "Resize list".
emptyText string | null null Text in the detail pane while nothing is open; null uses "Select an item to see its details." The empty slot replaces it.

Slots

  • header — Optional bar above both panes (title, counts, list actions). Its attributes merge onto the header wrapper.
  • list — The list pane content. Mark each row that opens the detail with data-split-view-open; a link keeps its href so it navigates without JavaScript.
  • detail — The open item's content. Its attributes merge onto the content wrapper. Leave it empty to show the empty state.
  • empty — Optional replacement for the empty-state text shown while nothing is open.

Data slots

Stable hooks for CSS overrides and browser tests.

split-view split-view-back split-view-detail split-view-detail-content split-view-empty split-view-header split-view-list

Behavior

  • From a 48rem container width the list and detail sit side by side (list on the inline-start side, so it moves to the right in RTL) with the composed resizable divider: drag it or use Arrow keys (Shift for 10%, Home/End for the limits, Enter or double-click to reset). The width is clamped to minListSize/maxListSize and persists under the persist key.
  • Below 48rem only one pane shows. Opening slides the detail in from the inline end and moves focus to the detail region; Back, Escape, close(), the model or the close-split-view event return to the list and focus the element that opened the detail (else the list row with aria-current, else the list region). IC-003.
  • Escape closes the stacked detail only when focus is not in a text field, select, contenteditable, menu, listbox, combobox, dialog or a control with an expanded popup, and only when no inner handler already prevented it. Side by side, Escape does nothing.
  • Clicking any [data-split-view-open] element in its own list opens the detail and records it as the opener; the component never cancels the click, so links navigate unless the consumer prevents it (wire:click.prevent, x-on:click.prevent).
  • detailMode on-demand (data-detail-mode="on-demand" on the root): side by side the detail content shows whatever the open state is; stacked, closed shows the list and header, open shows only the detail pane with Back. Opening, Back, Escape and focus return work exactly as in toggle mode, and the stacked slide is the same transition.
  • Open state from outside: bind it with x-model or wire:model on the root (x-modelable), or listen for split-view-open and split-view-close. Inside the slots the component's Alpine scope exposes only splitViewOpen, splitViewStacked, open(), close() and splitViewEscape(), so a consumer variable with any other name (detailOpen, selected, stacked) is not shadowed.
  • JS API: Alpine.$data(root).open(opener?), close() and the splitViewOpen state; dispatches bubbling split-view-open and split-view-close events with detail { id } and listens on window for open-split-view and close-split-view (optional detail.id targets one instance).
  • Without JavaScript (IC-013, progressive enhancement) nothing is hidden: both panes render side by side at the default split, or stacked in a narrow container with the detail content after the list, and the row links navigate.
  • The root is a size container that needs a height from the consumer (for example class="h-dvh" or a flex-1 parent); each pane scrolls on its own. Long labels truncate or wrap and a 320px container shows one pane without page overflow (IC-015).
  • Without an id the root gets a stable default id (split-view-<8 hex>) from the persist key, else from the list, detail and back labels, the detail mode and the list size, so the pane ids stay the same across Livewire renders. A second split view with the same base id in one request gets -2, -3, so default split views on one page never share ids; give each one its own id when several sit in different Livewire components on one page.
  • Fits the content outlet of an application shell (VC-001) and gives a detail page (VC-006) a list context; visualContractIds are block/page-only, so the contracts are named here.
  • 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

Progressive disclosure

Reveal optional or secondary content on demand.

Use when

  • Use for secondary or optional content, especially when users usually inspect one short section at a time.
  • Building a triage screen (inbox, queue, review list) where picking a row shows its detail beside the list.
  • Needing the list/detail pair to collapse to one pane with a Back control on narrow screens and inside narrow app shells.

Avoid when

  • Do not hide required workflow content or comparison-heavy information because disclosure controls reduce visibility and increase interaction cost.
  • The two panels are peers with no list/detail relationship (code and preview, editor and inspector); use resizable.
  • The detail should cover the page as an overlay without leaving the list context; use sheet or drawer.
  • The page shows one record with no list beside it; use a detail page layout.

Use instead

  • Visible sections
  • Tabs for a few long peer sections

Anti-patterns

  • Hiding required workflow content
  • Using accordions for cross-section comparison
Anatomy
root split-view-header split-view-list split-view-detail split-view-back split-view-detail-content split-view-empty
Theming hooks
split-view header split-view back split-view empty

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
managed
  • The list and detail are named regions (listLabel, detailLabel); the divider is a focusable separator with aria-valuenow, aria-valuemin, aria-valuemax and aria-controls pointing at the list.
  • Focus moves into the detail region when it replaces the list and returns to the opening row on every close path (IC-003); the Back button is a default-size ghost button (the density-aware control tier) with a chevron that flips in RTL.
  • The narrow-container slide is a CSS transition on translate that reduced motion (OS setting or data-motion=reduced) removes; the pane still swaps instantly.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

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

livewire-component.blade.php Blade
<div wire:key="split-view-{{ $record->id }}">
    {{-- Give the root a height. Each row is a real link with data-split-view-open:
         with JavaScript it opens the detail pane (and, in a narrow container, slides
         it in with a Back button); without it the link still navigates. --}}
    @php
        $messages = [
            ['id' => 1, 'from' => 'Ada Lovelace', 'subject' => __('Quarterly report draft'), 'preview' => __('The figures for the third quarter are in. Can you check the totals?'), 'time' => __('09:12')],
            ['id' => 2, 'from' => 'Grace Hopper', 'subject' => __('Deploy window moved'), 'preview' => __('We moved the release to Thursday afternoon.'), 'time' => __('08:40')],
            ['id' => 3, 'from' => 'Alan Turing', 'subject' => __('Invoice 2041 paid'), 'preview' => __('Payment arrived this morning. No action needed.'), 'time' => __('Yesterday')],
        ];
    @endphp
    <div x-data="{ selected: null }" class="w-full">
        <x-ui.split-view class="h-96 w-full overflow-hidden rounded-lg border border-border" persist="split-view-demo">
            <x-slot:header class="flex items-center justify-between gap-2 px-4 py-2">
                <h2 class="text-sm font-semibold">{{ __('Inbox') }}</h2>
                <span class="text-sm text-muted-foreground">{{ __('3 messages') }}</span>
            </x-slot:header>
    
            <x-slot:list>
                <ul role="list" class="divide-y divide-border">
                    @foreach ($messages as $message)
                        <li>
                            <a
                                href="#message-{{ $message['id'] }}"
                                data-split-view-open
                                x-on:click.prevent="selected = {{ $message['id'] }}"
                                :aria-current="selected === {{ $message['id'] }} ? 'true' : null"
                                class="flex min-h-11 min-w-0 flex-col gap-1 px-4 py-2 outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring aria-[current=true]:bg-muted"
                            >
                                <span class="flex min-w-0 items-baseline justify-between gap-2">
                                    <span class="truncate text-sm font-medium">{{ $message['from'] }}</span>
                                    <span class="shrink-0 text-xs text-muted-foreground">{{ $message['time'] }}</span>
                                </span>
                                <span class="truncate text-sm">{{ $message['subject'] }}</span>
                                <span class="truncate text-xs text-muted-foreground">{{ $message['preview'] }}</span>
                            </a>
                        </li>
                    @endforeach
                </ul>
            </x-slot:list>
    
            <x-slot:detail>
                @foreach ($messages as $message)
                    <article id="message-{{ $message['id'] }}" x-show="selected === {{ $message['id'] }}" class="flex flex-col gap-2 p-6">
                        <h3 class="text-sm font-semibold">{{ $message['subject'] }}</h3>
                        <p class="text-xs text-muted-foreground">{{ __('From :name at :time', ['name' => $message['from'], 'time' => $message['time']]) }}</p>
                        <p class="text-sm">{{ $message['preview'] }}</p>
                    </article>
                @endforeach
            </x-slot:detail>
        </x-ui.split-view>
    </div>
</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/split-view.blade.php Blade
@props([
    // Server-rendered open state; x-model / wire:model on the root take over after boot.
    'detailOpen' => false,
    // toggle: the open state shows or hides the detail in both layouts.
    // on-demand: side by side the detail slot always shows (the empty state
    // only when the slot is empty); stacked, the list and header show first
    // and the detail pane appears only once it is opened.
    'detailMode' => 'toggle',
    // List pane share of the width in percent, and its resize limits.
    'listSize' => 36,
    'minListSize' => 24,
    'maxListSize' => 56,
    // localStorage key that remembers the resized list width; null keeps it for the page only.
    'persist' => null,
    'backLabel' => null,
    'listLabel' => null,
    'detailLabel' => null,
    'resizeLabel' => null,
    // Text shown in the detail pane while nothing is open (the `empty` slot overrides it).
    'emptyText' => null,
])

@php
    $minListSize = max(10, min(90, (float) $minListSize));
    $maxListSize = max($minListSize, min(90, (float) $maxListSize));
    $listSize = max($minListSize, min($maxListSize, (float) $listSize));
    $detailSize = 100 - $listSize;
    $detailMode = $detailMode === 'on-demand' ? 'on-demand' : 'toggle';
    // A stable default id: the pane ids derive from it, and an id that
    // changed on every render made Livewire morph the panes as new elements.
    // It comes from the persist key, else from the labels, mode and size. A
    // second split view with the same base in one request gets -2, -3, so
    // two default split views on one page never share ids.
    $rootId = $attributes->get('id');
    if (blank($rootId)) {
        $rootId = 'split-view-'.substr(md5(filled($persist)
            ? 'persist|'.$persist
            : implode('|', [(string) $listLabel, (string) $detailLabel, (string) $backLabel, $detailMode, (string) $listSize])), 0, 8);
        $splitViewIds = request()->attributes->get('brok.split-view.ids', []);
        $splitViewIds[$rootId] = ($splitViewIds[$rootId] ?? 0) + 1;
        request()->attributes->set('brok.split-view.ids', $splitViewIds);
        if ($splitViewIds[$rootId] > 1) {
            $rootId .= '-'.$splitViewIds[$rootId];
        }
    }
    $hasDetail = isset($detail) && $detail->isNotEmpty();
    $hasEmpty = isset($empty) && $empty->isNotEmpty();
    $onDemand = $detailMode === 'on-demand';
    // on-demand, side by side (a 48rem split-view container): the detail slot
    // shows whatever the open state is. The important modifier outranks the
    // open/closed rules in ui.css.
    $wideContentClass = $onDemand ? ' @3xl/split-view:!block' : '';
    $wideEmptyClass = $onDemand ? ($hasDetail ? ' @3xl/split-view:!hidden' : ' @3xl/split-view:!flex') : '';
@endphp

{{--
    Split View — the list + detail layout of a triage (inbox) screen. Side by
    side, the list and detail panes share a resizable divider (composes
    `resizable`). In a narrow container only one pane shows: opening an item
    slides the detail in with a Back button; Back and Escape return to the list
    and put focus back on the row that opened it. Any element in the list with
    `data-split-view-open` opens the detail when clicked; a link keeps its href,
    so the list still navigates without JavaScript. Give the root a height.

    The component's own Alpine state is namespaced (`splitViewOpen`,
    `splitViewStacked`), so a consumer variable such as `detailOpen` in an
    outer x-data is not shadowed inside the slots. Bind the open state with
    x-model / wire:model on the root, or listen for split-view-open and
    split-view-close.
--}}
<div
    x-data="uiSplitView({ open: @js((bool) $detailOpen) })"
    x-modelable="splitViewOpen"
    data-slot="split-view"
    data-state="{{ $detailOpen ? 'open' : 'closed' }}"
    @if ($onDemand) data-detail-mode="on-demand" @endif
    :data-state="splitViewOpen ? 'open' : 'closed'"
    :data-stacked="splitViewStacked ? 'true' : 'false'"
    {{ $attributes->merge(['id' => $rootId, 'class' => 'flex min-h-0 min-w-0 flex-col bg-background text-foreground']) }}
>
    @isset($header)
        <div
            data-slot="split-view-header"
            @if ($onDemand) x-show="!(splitViewStacked && splitViewOpen)" @endif
            {{ $header->attributes->merge(['class' => 'min-w-0 shrink-0 border-b border-border']) }}
        >
            {{ $header }}
        </div>
    @endisset

    <x-ui.resizable :persist="$persist" class="min-h-0 flex-1">
        <x-ui.resizable.panel
            data-split-view-pane="list"
            :default-size="$listSize"
            :min-size="$minListSize"
            :max-size="$maxListSize"
            style="width: {{ $listSize }}%"
        >
            <section
                id="{{ $rootId }}-list"
                data-slot="split-view-list"
                tabindex="-1"
                aria-label="{{ filled($listLabel) ? $listLabel : __('List') }}"
                class="min-h-full min-w-0 break-words outline-none"
            >
                {{ $list ?? '' }}
            </section>
        </x-ui.resizable.panel>

        <x-ui.resizable.handle
            :label="filled($resizeLabel) ? $resizeLabel : __('Resize list')"
            aria-controls="{{ $rootId }}-list"
        />

        <x-ui.resizable.panel
            data-split-view-pane="detail"
            :default-size="$detailSize"
            :min-size="100 - $maxListSize"
            :max-size="100 - $minListSize"
            style="width: {{ $detailSize }}%"
        >
            <section
                id="{{ $rootId }}-detail"
                data-slot="split-view-detail"
                tabindex="-1"
                aria-label="{{ filled($detailLabel) ? $detailLabel : __('Detail') }}"
                x-on:keydown.escape="splitViewEscape($event)"
                class="min-h-full min-w-0 break-words bg-background outline-none"
            >
                <div data-slot="split-view-back" class="sticky top-0 z-10 border-b border-border bg-background px-2 py-1">
                    <x-ui.button variant="ghost" x-on:click="close()">
                        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 rtl:-scale-x-100"><path d="m15 18-6-6 6-6" /></svg>
                        <span class="truncate">{{ filled($backLabel) ? $backLabel : __('Back') }}</span>
                    </x-ui.button>
                </div>

                @if ($hasDetail)
                    <div data-slot="split-view-detail-content" {{ $detail->attributes->merge(['class' => 'min-w-0'.$wideContentClass]) }}>
                        {{ $detail }}
                    </div>
                @endif

                <div data-slot="split-view-empty" class="flex min-h-full items-center justify-center p-6 text-center text-sm text-muted-foreground{{ $wideEmptyClass }}">
                    @if ($hasEmpty)
                        {{ $empty }}
                    @else
                        <p>{{ filled($emptyText) ? $emptyText : __('Select an item to see its details.') }}</p>
                    @endif
                </div>
            </section>
        </x-ui.resizable.panel>
    </x-ui.resizable>
</div>
resources/js/ui/split-view.js JS
/**
 * Split View behaviour — the list + detail layout of a triage screen.
 *
 * `splitViewOpen` is the single open state (x-modelable, so x-model and
 * wire:model on the root drive it). The state is namespaced so it never
 * shadows a consumer variable of the same plain name inside the slots.
 * The layout itself is CSS: a container query on the root stacks the panes
 * below 48rem and sets `--split-view-stacked: 1` on the resizable group,
 * which this component reads to know which mode it is in.
 * The divider is the composed `resizable` primitive; nothing here drags.
 *
 * Opening records the element that opened it (a clicked `[data-split-view-open]`
 * row, or the focused list element). When stacked, opening moves focus to the
 * detail region, and every close path (Back, Escape, close(), the model, the
 * `close-split-view` event) returns focus to that opener. Side by side, focus
 * returns only when it was inside the detail pane.
 *
 * Events: dispatches `split-view-open` / `split-view-close` (bubbling, detail
 * `{ id }`) and listens on window for `open-split-view` / `close-split-view`
 * (optional `detail.id` targets one instance by its root id).
 *
 * Self-registers on `alpine:init` so import order does not matter.
 */
document.addEventListener('alpine:init', () => {
    // Where Escape belongs to a nested control rather than to the split view.
    const OWNS_ESCAPE = [
        'textarea',
        'select',
        'input:not([type=checkbox]):not([type=radio]):not([type=button]):not([type=submit]):not([type=reset])',
        '[contenteditable]:not([contenteditable=false])',
        '[role=menu]',
        '[role=menubar]',
        '[role=listbox]',
        '[role=combobox]',
        '[role=dialog]',
        '[role=alertdialog]',
        '[aria-modal=true]',
        'dialog',
        '[aria-expanded=true]',
    ].join(',');

    window.Alpine.data('uiSplitView', (config = {}) => {
        // Only the namespaced state, open(), close() and splitViewEscape()
        // live on the Alpine scope: everything in it is visible to the list
        // and detail slots, so any other name could shadow a consumer's own
        // variable (a `detailOpen` in an outer x-data). Helpers and bookkeeping
        // stay in this closure. Methods also run from nested scopes (the
        // resizable panels), where `this.$el` is the nearest nested element,
        // so the root and the reactive scope are kept here too.
        let root = null;
        let scope = null;
        let opener = null;
        let observer = null;
        let onClick = null;
        let onOpenEvent = null;
        let onCloseEvent = null;

        const targets = (event) => {
            const id = event.detail?.id;
            return !id || id === root.id;
        };

        const pane = (name) => root.querySelector(`#${CSS.escape(root.id)}-${name}`);

        const measure = () => {
            const group = root.querySelector(':scope > [data-slot="resizable"]');
            scope.splitViewStacked = Boolean(group) && getComputedStyle(group).getPropertyValue('--split-view-stacked').trim() === '1';
        };

        /** The opener, else the list's current row, else the list region. */
        const returnFocus = () => {
            const list = pane('list');
            const current = list?.querySelector('[data-split-view-open][aria-current]:not([aria-current=false])');
            const target = [opener, current, list].find((el) => el?.isConnected && el.getClientRects().length);
            target?.focus();
        };

        const changed = (open) => {
            root.dispatchEvent(new CustomEvent(open ? 'split-view-open' : 'split-view-close', { bubbles: true, detail: { id: root.id } }));
            const detail = pane('detail');
            if (open) {
                if (scope.splitViewStacked) window.Alpine.nextTick(() => detail?.focus({ preventScroll: true }));
                return;
            }
            const focusWasInDetail = detail?.contains(document.activeElement);
            if (!scope.splitViewStacked && !focusWasInDetail) return;
            window.Alpine.nextTick(returnFocus);
        };

        return {
            splitViewOpen: Boolean(config.open),
            splitViewStacked: false,

            init() {
                root = this.$el;
                scope = this;
                measure();
                if (typeof ResizeObserver === 'function') {
                    observer = new ResizeObserver(measure);
                    observer.observe(root);
                }

                onClick = (event) => {
                    const trigger = event.target.closest?.('[data-split-view-open]');
                    if (trigger && trigger.closest('[data-slot="split-view"]') === root) scope.open(trigger);
                };
                root.addEventListener('click', onClick);

                onOpenEvent = (event) => {
                    if (targets(event)) scope.open();
                };
                onCloseEvent = (event) => {
                    if (targets(event)) scope.close();
                };
                window.addEventListener('open-split-view', onOpenEvent);
                window.addEventListener('close-split-view', onCloseEvent);

                this.$watch('splitViewOpen', (open) => changed(Boolean(open)));
            },

            destroy() {
                observer?.disconnect();
                root?.removeEventListener('click', onClick);
                window.removeEventListener('open-split-view', onOpenEvent);
                window.removeEventListener('close-split-view', onCloseEvent);
            },

            /** Open the detail pane. `trigger` gets focus back on close. */
            open(trigger = null) {
                const list = pane('list');
                const active = document.activeElement;
                opener = trigger ?? (list?.contains(active) && active !== list ? active : opener);
                scope.splitViewOpen = true;
            },

            close() {
                scope.splitViewOpen = false;
            },

            /** Escape on the stacked detail pane returns to the list. */
            splitViewEscape(event) {
                if (!scope.splitViewStacked || !scope.splitViewOpen || event.defaultPrevented) return;
                if (event.target.closest(OWNS_ESCAPE)) return;
                event.preventDefault();
                scope.close();
            },
        };
    });
});

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