Skip to content
Brok UI

Loading…

No results

Modal Surfaces

Open source Core

The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.

Version
v1.8.1
Stability
stable
License
MIT
Related
Dialog
Drawer
Sidebar
Popover

Sheet — A dialog that slides in from a screen edge, with focus trap, Escape-to-close, focus restoration, background inertness, a width `size` preset, an opt-in keyboard-resizable width and a teleported overlay.

Preview

Side
Size
previews.components.sheet.default.blade.php Blade
<x-ui.sheet>
    <x-ui.sheet.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Open menu') }}
    </x-ui.sheet.trigger>

    <x-ui.sheet.content side="end">
        <x-ui.sheet.close />
        <x-ui.sheet.header>
            <x-ui.sheet.title>{{ __('Navigation') }}</x-ui.sheet.title>
            <x-ui.sheet.description>{{ __('Browse the sections of your account from here.') }}</x-ui.sheet.description>
        </x-ui.sheet.header>

        <nav class="mt-6 flex flex-col gap-1 text-sm">
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Dashboard') }}</a>
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Settings') }}</a>
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Billing') }}</a>
        </nav>

        <x-ui.sheet.footer>
            <button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
                {{ __('Close') }}
            </button>
        </x-ui.sheet.footer>
    </x-ui.sheet.content>
</x-ui.sheet>

Side options

End Current
Position Current
EnterStart Current
EnterEnd Current
ResizeEdge Current
Start Current
Top Current
Bottom Current

Size options

Sm Current
Md Current
Lg Current
Xl Current
Full Current

Installation

terminal
php artisan ui:add sheet

Note

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

resources/js/ui/index.js JS
import './sheet.js';

Registry contract

php artisan ui:add sheet 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/sheet.blade.php
  • blade resources/views/components/ui/sheet/trigger.blade.php
  • blade resources/views/components/ui/sheet/content.blade.php
  • blade resources/views/components/ui/sheet/header.blade.php
  • blade resources/views/components/ui/sheet/title.blade.php
  • blade resources/views/components/ui/sheet/description.blade.php
  • blade resources/views/components/ui/sheet/footer.blade.php
  • blade resources/views/components/ui/sheet/close.blade.php
  • js resources/js/ui/sheet.js
Registry dependencies
dialog overlay
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.

sheet.md
# Brok UI: Sheet (`sheet`)

A dialog that slides in from a screen edge, with focus trap, Escape-to-close, focus restoration, background inertness, a width `size` preset, an opt-in keyboard-resizable width and a teleported overlay.

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

## Install

```bash
php artisan ui:add sheet
```

## Usage

```blade
<x-ui.sheet>
    <x-ui.sheet.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Open menu') }}
    </x-ui.sheet.trigger>

    <x-ui.sheet.content side="end">
        <x-ui.sheet.close />
        <x-ui.sheet.header>
            <x-ui.sheet.title>{{ __('Navigation') }}</x-ui.sheet.title>
            <x-ui.sheet.description>{{ __('Browse the sections of your account from here.') }}</x-ui.sheet.description>
        </x-ui.sheet.header>

        <nav class="mt-6 flex flex-col gap-1 text-sm">
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Dashboard') }}</a>
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Settings') }}</a>
            <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Billing') }}</a>
        </nav>

        <x-ui.sheet.footer>
            <button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
                {{ __('Close') }}
            </button>
        </x-ui.sheet.footer>
    </x-ui.sheet.content>
</x-ui.sheet>
```

## Props

- `side` (end|position|enterStart|enterEnd|resizeEdge|start|top|bottom, default `end`) — Documented catalog control used by the preview workbench.
- `size` (sm|md|lg|xl|full, default `sm`) — Documented catalog control used by the preview workbench.
- `fullOnMobile` (bool, default `false`) — Declared by @props in the registry Blade source.
- `resizable` (bool, default `false`) — Declared by @props in the registry Blade source.
- `resizeMin` (int, default `320`) — Declared by @props in the registry Blade source.
- `resizeMax` (int, default `640`) — Declared by @props in the registry Blade source.
- `resizeKey` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sticky` (bool, default `false`) — Declared by @props in the registry Blade source.

## Use when

- Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
- Showing supplementary controls, navigation, or details without replacing the page.
- An edge-aligned panel should preserve context behind it.

## Avoid when

- Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
- Prefer over a dialog when users need more room for a supporting task while preserving page context.
- The task requires a centered, strongly modal decision; use dialog.
- A small non-modal panel is sufficient; use popover.

## Anti-patterns

- Long forms in dialogs
- Multi-step or comparison-heavy modal workflows

## Rules

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

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

Examples

peek-navigation.blade.php Blade
{{--
    A "peek" panel that steps between records without closing: Next/Previous
    swap the subject in place, announceSubjectChange() tells assistive tech
    what changed, an inline "hold reason" field claims the first Escape while
    it has unsaved text, and the panel's width is keyboard/pointer resizable
    and remembered per browser. `size="lg"` also demonstrates composing the
    width preset with `resizable`: it sets the starting width, and the resize
    handle (plus its persisted resize-key width) then wins over it.
--}}
<x-ui.sheet>
    <x-ui.sheet.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Open record') }}
    </x-ui.sheet.trigger>

    <x-ui.sheet.content side="end" size="lg" resizable resize-key="docs-peek-navigation-width">
        <div
            data-testid="peek-navigation"
            x-data="{
                records: [
                    { id: 'ORD-1001', name: 'Ada Lovelace', total: '$128.00' },
                    { id: 'ORD-1002', name: 'Grace Hopper', total: '$64.50' },
                    { id: 'ORD-1003', name: 'Alan Turing', total: '$412.10' },
                ],
                index: 0,
                get record() { return this.records[this.index]; },
                step(delta) {
                    this.index = Math.max(0, Math.min(this.records.length - 1, this.index + delta));
                    this.announceSubjectChange(`${this.record.id}, ${this.record.name}, ${this.record.total}`);
                },
            }"
        >
            <x-ui.sheet.close />
            <x-ui.sheet.header>
                <x-ui.sheet.title data-testid="peek-title" x-text="record.id"></x-ui.sheet.title>
                <x-ui.sheet.description data-testid="peek-description" x-text="`${record.name} — ${record.total}`"></x-ui.sheet.description>
            </x-ui.sheet.header>

            <div class="mt-4 flex items-center gap-2">
                <button
                    type="button"
                    data-testid="peek-previous"
                    @click="step(-1)"
                    :disabled="index === 0"
                    class="inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
                >{{ __('Previous') }}</button>
                <button
                    type="button"
                    data-testid="peek-next"
                    @click="step(1)"
                    :disabled="index === records.length - 1"
                    class="inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40"
                >{{ __('Next') }}</button>
                <span class="text-xs tabular-nums text-muted-foreground" x-text="`${index + 1} / ${records.length}`"></span>
            </div>

            {{-- A half-typed hold reason should not be lost to the same Escape
                 press that would otherwise close the panel: this claims the
                 first Escape while it is active. --}}
            <div
                class="mt-6 space-y-2"
                x-data="{
                    editing: false,
                    reason: '',
                    init() {
                        // A stable closure that only touches this local scope's
                        // own reactive state, so it works correctly however it
                        // is later invoked (pushEscapeLayer calls it as a bare
                        // function, with no receiver).
                        this.escapeClose = () => {
                            this.reason = '';
                            this.editing = false;
                        };
                    },
                }"
                x-effect="editing ? pushEscapeLayer(escapeClose) : popEscapeLayer(escapeClose)"
            >
                <button
                    type="button"
                    data-testid="peek-add-hold-reason"
                    x-show="!editing"
                    @click="editing = true"
                    class="inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
                >{{ __('Add hold reason') }}</button>
                <div x-show="editing" x-cloak class="space-y-1">
                    <x-ui.label for="peek-hold-reason">{{ __('Hold reason') }}</x-ui.label>
                    <x-ui.input id="peek-hold-reason" data-testid="peek-hold-reason-input" x-model="reason" placeholder="{{ __('e.g. awaiting payment confirmation') }}" />
                    <p class="text-xs text-muted-foreground">{{ __('Escape clears this field first; press it again to close the panel.') }}</p>
                </div>
            </div>

            {{-- A destructive action stacks a second modal on top of the sheet.
                 Both are open at once: the sheet's own portal stays interactive
                 the whole time, and closing the alert dialog must not un-inert
                 the page while the sheet is still open behind it. --}}
            <x-ui.alert-dialog>
                <x-ui.alert-dialog.trigger data-testid="peek-remove" class="mt-6 inline-flex h-9 items-center justify-center rounded-md border border-destructive/40 bg-background px-3 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10">
                    {{ __('Remove record') }}
                </x-ui.alert-dialog.trigger>

                <x-ui.alert-dialog.content>
                    <x-ui.alert-dialog.header>
                        <x-ui.alert-dialog.title>{{ __('Remove this record?') }}</x-ui.alert-dialog.title>
                        <x-ui.alert-dialog.description>{{ __('This cannot be undone.') }}</x-ui.alert-dialog.description>
                    </x-ui.alert-dialog.header>
                    <x-ui.alert-dialog.footer>
                        <x-ui.alert-dialog.cancel data-testid="peek-remove-cancel">{{ __('Cancel') }}</x-ui.alert-dialog.cancel>
                        <x-ui.alert-dialog.action data-testid="peek-remove-confirm">{{ __('Remove') }}</x-ui.alert-dialog.action>
                    </x-ui.alert-dialog.footer>
                </x-ui.alert-dialog.content>
            </x-ui.alert-dialog>
        </div>
    </x-ui.sheet.content>
</x-ui.sheet>
responsive.blade.php Blade
<x-ui.sheet>
    <x-ui.sheet.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
        {{ __('Open task') }}
    </x-ui.sheet.trigger>

    <x-ui.sheet.content size="xl" full-on-mobile>
        <x-ui.sheet.close />
        <x-ui.sheet.header>
            <x-ui.sheet.title>{{ __('Prepare the quarterly budget review for the finance team') }}</x-ui.sheet.title>
            <x-ui.sheet.description>{{ __('Full width on a phone, a side panel from the small breakpoint up.') }}</x-ui.sheet.description>
        </x-ui.sheet.header>

        <p class="mt-6 text-sm text-muted-foreground">
            {{ __('Collect the figures from each project, compare them with the plan and list the open questions before the meeting.') }}
        </p>
    </x-ui.sheet.content>
</x-ui.sheet>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
side end | position | enterStart | enterEnd | resizeEdge | start | top | bottom end Documented catalog control used by the preview workbench.
size sm | md | lg | xl | full sm Documented catalog control used by the preview workbench.
fullOnMobile bool false Declared by @props in the registry Blade source.
resizable bool false Declared by @props in the registry Blade source.
resizeMin int 320 Declared by @props in the registry Blade source.
resizeMax int 640 Declared by @props in the registry Blade source.
resizeKey mixed | null null Declared by @props in the registry Blade source.
sticky bool false Declared by @props in the registry Blade source.

Slots

  • default — Primary Blade slot rendered by the component.
  • x-ui.sheet.trigger — Installed subcomponent from the registry item.
  • x-ui.sheet.content — Installed subcomponent from the registry item.
  • x-ui.sheet.header — Installed subcomponent from the registry item.
  • x-ui.sheet.title — Installed subcomponent from the registry item.
  • x-ui.sheet.description — Installed subcomponent from the registry item.
  • x-ui.sheet.footer — Installed subcomponent from the registry item.
  • x-ui.sheet.close — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

sheet sheet-close sheet-content sheet-description sheet-footer sheet-header sheet-live-region sheet-overlay sheet-portal sheet-resize-handle sheet-title sheet-trigger

Behavior

  • Traps focus, closes on Escape, restores focus, and mirrors start/end placement in RTL.
  • Marks the rest of the page inert while open (reference-counted across stacked overlays) so a screen reader on a virtual cursor cannot browse behind the panel.
  • A nested layer can call pushEscapeLayer(close)/popEscapeLayer(close) to claim the first Escape press instead of the panel closing; the global Escape handler also defers to any keydown that already called preventDefault().
  • Call announceSubjectChange(message) to tell assistive tech an already-open panel's content changed in place (e.g. stepping to another record); ordinary open is not announced twice.
  • resizable renders a role=separator handle with aria-valuemin/max/now; drag or ArrowLeft/Right/Up/Down/Home/End resize it, and resizeKey persists the width across visits.
  • full-on-mobile makes a start/end panel span the screen below sm and keeps the size width from sm up, so a phone does not get a three-quarter panel that cuts titles and forms.
  • size sets the panel's starting width (sm/md/lg/xl/full); when resizable is also set, the handle's drag/keyboard width — and any resizeKey-persisted width — wins over the size-driven starting width, the same way it already wins over an unmatched utility class.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Focused overlay

Handle a short focused interruption or blocking decision.

Use when

  • Use for short, self-contained work that benefits from preserving page context, or for interruptions that truly deserve focused attention.
  • Showing supplementary controls, navigation, or details without replacing the page.
  • An edge-aligned panel should preserve context behind it.

Avoid when

  • Do not use for long forms, multi-step tasks, or broad comparison work that needs more room and less interruption.
  • Prefer over a dialog when users need more room for a supporting task while preserving page context.
  • The task requires a centered, strongly modal decision; use dialog.
  • A small non-modal panel is sufficient; use popover.

Use instead

  • Inline content
  • Drawer or dedicated page for longer tasks

Anti-patterns

  • Long forms in dialogs
  • Multi-step or comparison-heavy modal workflows
Anatomy
root trigger overlay content header title description footer close
Theming hooks
sheet overlay sheet content

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
Focus
managed
  • Provide a title even when it is visually hidden.
  • Use logical start/end sides for RTL-safe placement.
  • Background content is inert while the sheet is open; do not rely on aria-hidden or z-index alone to keep it from a virtual cursor.
  • 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="sheet-{{ $record->id }}">
    <x-ui.sheet>
        <x-ui.sheet.trigger class="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            {{ __('Open menu') }}
        </x-ui.sheet.trigger>
    
        <x-ui.sheet.content side="end">
            <x-ui.sheet.close />
            <x-ui.sheet.header>
                <x-ui.sheet.title>{{ __('Navigation') }}</x-ui.sheet.title>
                <x-ui.sheet.description>{{ __('Browse the sections of your account from here.') }}</x-ui.sheet.description>
            </x-ui.sheet.header>
    
            <nav class="mt-6 flex flex-col gap-1 text-sm">
                <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Dashboard') }}</a>
                <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Settings') }}</a>
                <a href="#" class="rounded-md px-3 py-2 text-foreground transition-colors hover:bg-muted">{{ __('Billing') }}</a>
            </nav>
    
            <x-ui.sheet.footer>
                <button type="button" @click="hide()" class="inline-flex h-10 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted">
                    {{ __('Close') }}
                </button>
            </x-ui.sheet.footer>
        </x-ui.sheet.content>
    </x-ui.sheet>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:sheet
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/sheet.blade.php Blade
{{--
    Sheet (dialog that slides in from a screen edge).
    Root holds open state, focus trapping and focus restoration. Each instance
    is self-contained, so multiple and nested sheets work.
--}}
<div
    x-data="uiSheet()"
    x-modelable="open"
    x-id="['sheet-title', 'sheet-description']"
    data-slot="sheet"
    {{ $attributes->merge(['class' => 'contents']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/sheet/trigger.blade.php Blade
<button
    type="button"
    data-slot="sheet-trigger"
    x-ref="trigger"
    aria-haspopup="dialog"
    :aria-expanded="open"
    @click="show()"
    {{ $attributes->merge(['class' => 'inline-flex items-center justify-center']) }}
>
    {{ $slot }}
</button>
resources/views/components/ui/sheet/content.blade.php Blade
@props([
    'side' => 'end',
    // Panel width preset — start/end sides only (see $sizes below). Defaults
    // to 'sm' to reproduce the original hard-coded `max-w-sm` exactly, so
    // existing usages are unaffected. Choosing a width through this prop
    // (instead of an appended `class="max-w-*"`) is the supported escape
    // hatch: a consumer class lands in the attribute bag at the same
    // specificity as the recipe's own `max-w-*`, so which one wins depends on
    // Tailwind's generated stylesheet order rather than intent.
    'size' => 'sm',
    // Opt-in: below the `sm` breakpoint (a phone) the start/end panel spans
    // the full width, and from `sm` up it is the `size` side panel. A
    // three-quarter panel on a 390px screen cuts titles and forms. Off by
    // default, so existing sheets keep their width on every screen.
    'fullOnMobile' => false,
    // Opt-in resize handle (width only, edge-aligned sides). Off by default,
    // so an unresized sheet's markup and behavior are unchanged. When used
    // together with `size`, the preset is only the *starting* width — the
    // handle (and any persisted resizeKey width) then wins over it, the same
    // way it already wins over the plain `max-w-*` utility (see the inline
    // style note below).
    'resizable' => false,
    'resizeMin' => 320,
    'resizeMax' => 640,
    // localStorage key to persist the chosen width per user/browser. Omit to
    // keep the resize session-only (still resizable, resets on reload) —
    // persistence is opt-in because a shared key across unrelated sheets
    // would leak one sheet's width into another's.
    'resizeKey' => null,
])

@php
    // Position + per-side transform transition classes (logical so they
    // mirror under RTL). 'start'/'end' use logical inline insets and take a
    // `%s` placeholder for the width classes computed below; 'top'/'bottom'
    // span the inline axis and size by content height, not width, so `size`
    // has no effect on them (documented as width-only in item.json — there is
    // no natural height scale to reuse the same prop for, and top/bottom
    // panels are typically content-sized rather than viewport-fraction
    // panels). RTL variants flip the off-screen translate direction.
    $sides = [
        'end' => [
            'position' => 'end-0 top-0 bottom-0 h-full %s border-s',
            'enterStart' => 'translate-x-full rtl:-translate-x-full',
            'enterEnd' => 'translate-x-0',
            'resizeEdge' => 'start-0',
        ],
        'start' => [
            'position' => 'start-0 top-0 bottom-0 h-full %s border-e',
            'enterStart' => '-translate-x-full rtl:translate-x-full',
            'enterEnd' => 'translate-x-0',
            'resizeEdge' => 'end-0',
        ],
        'top' => [
            'position' => 'inset-x-0 top-0 w-full border-b',
            'enterStart' => '-translate-y-full',
            'enterEnd' => 'translate-y-0',
            'resizeEdge' => null,
        ],
        'bottom' => [
            'position' => 'inset-x-0 bottom-0 w-full border-t',
            'enterStart' => 'translate-y-full',
            'enterEnd' => 'translate-y-0',
            'resizeEdge' => null,
        ],
    ];

    $config = $sides[$side] ?? $sides['end'];

    // Width presets for the edge-aligned sides. 'sm' reproduces the original
    // `w-3/4 max-w-sm` byte-for-byte. 'full' drops the viewport-fraction
    // width entirely rather than scaling it further, since a panel that is
    // already edge-to-edge has nothing left to be a fraction of.
    $sizes = [
        'sm' => 'w-3/4 max-w-sm',
        'md' => 'w-3/4 max-w-md',
        'lg' => 'w-3/4 max-w-lg',
        'xl' => 'w-3/4 max-w-xl',
        'full' => 'w-full max-w-none',
    ];
    $sizeKey = array_key_exists($size, $sizes) ? $size : 'sm';

    // The phone override is `!important` so it also beats the inline width a
    // resize handle (or a persisted resizeKey width) sets; the handle itself
    // is hidden below `sm`, where the panel has no room to resize.
    $isFullOnMobile = (bool) $fullOnMobile && $sizeKey !== 'full';
    $widthClasses = $sizes[$sizeKey] . ($isFullOnMobile ? ' max-sm:!w-full max-sm:!max-w-none' : '');

    $position = str_contains($config['position'], '%s')
        ? sprintf($config['position'], $widthClasses)
        : $config['position'];

    // Resizing is a width affordance, so it only applies to the two
    // edge-aligned sides — a top/bottom sheet ignores the prop rather than
    // resize its full-width panel. `full` is excluded too: it already has no
    // room to grow, so seeding the handle from the rendered width (see
    // show() in sheet.js) would immediately clamp the panel down to
    // `resizeMax` the moment it opens — the two mechanisms fighting instead
    // of composing. Pick 'xl' (or a custom `resizeMax`) if a resizable panel
    // needs to start wide.
    $showResizeHandle = $resizable && $config['resizeEdge'] !== null && $sizeKey !== 'full';
@endphp

<template x-teleport="body">
    <div
        data-slot="sheet-portal"
        x-ref="portal"
        x-show="open"
        x-cloak
        x-init="initResize({ resizable: @js($showResizeHandle), resizeMin: @js($resizeMin), resizeMax: @js($resizeMax), resizeKey: @js($resizeKey) })"
        class="fixed inset-0 z-modal"
        {{-- Defers to any surface that already handled the key (a nested menu
             calling preventDefault() on its own Escape) and to a nested layer
             that claims the first Escape via pushEscapeLayer() before this
             closes the panel itself. --}}
        @keydown.escape.window="open && !$event.defaultPrevented && escape()"
    >
        {{-- Announces an in-place subject change (see announceSubjectChange())
             without re-announcing on ordinary open, which the dialog role
             already covers. --}}
        <p data-slot="sheet-live-region" aria-live="polite" class="sr-only" x-text="subjectAnnouncement"></p>

        {{-- Overlay --}}
        <div
            data-slot="sheet-overlay"
            x-show="open"
            x-transition:enter="motion-safe:transition-opacity motion-safe:duration-300 motion-safe:ease-standard motion-reduce:transition-none"
            x-transition:enter-start="opacity-0"
            x-transition:enter-end="opacity-100"
            x-transition:leave="motion-safe:transition-opacity motion-safe:duration-200 motion-safe:ease-standard motion-reduce:transition-none"
            x-transition:leave-start="opacity-100"
            x-transition:leave-end="opacity-0"
            class="absolute inset-0 bg-scrim/50"
            @click="hide()"
        ></div>

        {{-- Panel --}}
        <div
            data-slot="sheet-content"
            data-side="{{ $side }}"
            data-size="{{ $sizeKey }}"
            @if ($isFullOnMobile && $config['resizeEdge'] !== null)
                data-full-on-mobile
            @endif
            x-ref="panel"
            role="dialog"
            aria-modal="true"
            tabindex="-1"
            x-bind:aria-labelledby="hasTitle ? $id('sheet-title') : null"
            x-bind:aria-describedby="hasDescription ? $id('sheet-description') : null"
            x-show="open"
            @if ($showResizeHandle)
                {{-- The dragged/persisted width wins over the `size`-driven
                     `max-w-*` utility below: inline style has higher
                     specificity than a class, same as the drag-tracked
                     transform below it. --}}
                x-bind:style="resizeWidth ? `width: ${resizeWidth}px; max-width: ${resizeWidth}px` : null"
            @endif
            {{-- Decelerate in (emphasized curve, so the panel arrives fast and
                 settles), quicker standard curve out — a symmetric ease-in-out
                 reads as sluggish for a panel that slides from an edge. --}}
            x-transition:enter="motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-emphasized motion-reduce:transition-none"
            x-transition:enter-start="{{ $config['enterStart'] }}"
            x-transition:enter-end="{{ $config['enterEnd'] }}"
            x-transition:leave="motion-safe:transition-transform motion-safe:duration-200 motion-safe:ease-standard motion-reduce:transition-none"
            x-transition:leave-start="{{ $config['enterEnd'] }}"
            x-transition:leave-end="{{ $config['enterStart'] }}"
            @keydown.tab.prevent="trap($event)"
            {{ $attributes->merge(['class' => 'fixed z-10 min-w-0 overflow-y-auto border-border bg-card p-dialog text-card-foreground shadow-lg will-change-transform motion-reduce:transition-none ' . $position]) }}
        >
            @if ($showResizeHandle)
                {{-- A focusable separator that changes a value is a window
                     splitter: without the range it announces its name and
                     nothing else, so the arrow keys below would appear to do
                     nothing to a screen-reader user. --}}
                <div
                    data-slot="sheet-resize-handle"
                    role="separator"
                    aria-orientation="vertical"
                    tabindex="0"
                    aria-label="{{ __('Resize panel') }}"
                    aria-valuemin="{{ (int) $resizeMin }}"
                    x-bind:aria-valuemax="resizeMax"
                    x-bind:aria-valuenow="resizeWidth ?? resizeMin"
                    @pointerdown="startResize($event)"
                    @keydown.arrow-left.prevent="resizeBy(-32)"
                    @keydown.arrow-right.prevent="resizeBy(32)"
                    @keydown.arrow-up.prevent="resizeBy(32)"
                    @keydown.arrow-down.prevent="resizeBy(-32)"
                    @keydown.home.prevent="setResizeWidth(resizeMin)"
                    @keydown.end.prevent="setResizeWidth(resizeMax)"
                    x-bind:class="resizing ? 'bg-ring' : 'bg-transparent'"
                    class="absolute inset-y-0 {{ $config['resizeEdge'] }} z-10 w-2 {{ $isFullOnMobile ? 'max-sm:hidden' : '' }} cursor-col-resize touch-none transition-colors hover:bg-ring/50 focus-visible:bg-ring focus-visible:outline-none"
                ></div>
            @endif

            {{ $slot }}
        </div>
    </div>
</template>
resources/views/components/ui/sheet/header.blade.php Blade
@props([
    // Pin the header to the top of a scrollable sheet body.
    'sticky' => false,
])

{{-- Anatomy shared with the other overlays — see ui/overlay/header.blade.php. --}}
<x-ui.overlay.header data-slot="sheet-header" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.header>
resources/views/components/ui/sheet/title.blade.php Blade
{{-- Anatomy shared with the other overlays — see ui/overlay/title.blade.php. --}}
<x-ui.overlay.title data-slot="sheet-title" scope="sheet-title" {{ $attributes }}>{{ $slot }}</x-ui.overlay.title>
resources/views/components/ui/sheet/description.blade.php Blade
{{-- Anatomy shared with the other overlays — see ui/overlay/description.blade.php. --}}
<x-ui.overlay.description data-slot="sheet-description" scope="sheet-description" {{ $attributes }}>{{ $slot }}</x-ui.overlay.description>
resources/views/components/ui/sheet/footer.blade.php Blade
@props([
    // Pin the footer to the bottom of a scrollable sheet body.
    'sticky' => false,
])

{{-- Anatomy shared with the other overlays — see ui/overlay/footer.blade.php. --}}
<x-ui.overlay.footer data-slot="sheet-footer" :sticky="$sticky" {{ $attributes }}>{{ $slot }}</x-ui.overlay.footer>
resources/views/components/ui/sheet/close.blade.php Blade
@props([
    // In a scrollable sheet, float the close button so it stays pinned to the
    // panel's top-right while the body scrolls (sticky instead of absolute).
    'sticky' => false,
])

{{-- Anatomy shared with the other overlays — see ui/overlay/close.blade.php. --}}
<x-ui.overlay.close data-slot="sheet-close" :sticky="$sticky" {{ $attributes }} />
resources/js/ui/sheet.js JS
// The dialog registry dependency installs this module beside this file.
import {
    announceOverlaySubjectChange,
    focusOverlayInitial,
    lockOverlayInert,
    lockOverlayScroll,
    overlayFocusables,
    overlayReturnFocusTarget,
    popOverlayEscapeLayer,
    pushOverlayEscapeLayer,
    resolveOverlayEscape,
    trapOverlayFocus,
    unlockOverlayInert,
    unlockOverlayScroll,
} from './overlay-lifecycle.js';

/**
 * Sheet behavior (focus trap, Escape, focus return).
 *
 * A sheet is a dialog that slides in from a screen edge. On open we remember
 * the element that had focus, move focus into the panel, and lock body scroll.
 * Tab is trapped inside the panel. On close we restore focus to the trigger.
 * Escape and overlay click both close.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSheet', () => ({
        open: false,
        previouslyFocused: null,
        // Set by <x-ui.sheet.title>/<x-ui.sheet.description> when present, so the
        // panel only advertises aria-labelledby/aria-describedby when the
        // referenced node actually exists (no dangling idref / nameless dialog).
        hasTitle: false,
        hasDescription: false,
        scrollLocked: false,
        inertLocked: false,
        inertPortal: null,
        escapeLayers: [],
        // Polite live region text — see announceSubjectChange().
        subjectAnnouncement: '',
        _focusRestoreTimer: null,

        // Opt-in resize handle state (see <x-ui.sheet.content resizable>).
        // Off by default; a resize handle only renders when `resizable` is
        // passed, so an unresized sheet's markup and behavior are unchanged.
        resizable: false,
        resizeMin: 320,
        resizeMax: 640,
        resizeKey: null,
        resizeWidth: null,
        resizing: false,
        _onResizeMove: null,
        _onResizeUp: null,

        // Body-scroll lock is reference-counted across stacked overlays so a
        // nested close (or an unmount while open) never clears the lock while
        // another overlay still needs it. The counter is a single shared global
        // key that every overlay item duplicates (copy-into-app model).
        lockScroll() {
            lockOverlayScroll(this);
        },

        unlockScroll() {
            unlockOverlayScroll(this);
        },

        lockInert() {
            lockOverlayInert(this, this.$refs.portal);
        },

        unlockInert() {
            unlockOverlayInert(this);
        },

        /** A nested layer (an inline form, a stepper) claims Escape while active. */
        pushEscapeLayer(close) {
            pushOverlayEscapeLayer(this, close);
        },

        popEscapeLayer(close) {
            popOverlayEscapeLayer(this, close);
        },

        /** Announce an in-place subject change without re-announcing on open. */
        announceSubjectChange(message) {
            announceOverlaySubjectChange(this, message);
        },

        /** Escape unwinds one layer at a time before it closes the panel. */
        escape() {
            if (!this.open) return;
            if (resolveOverlayEscape(this)) return;
            this.hide();
        },

        /**
         * Read a persisted width for `resizeKey` before the resize handle's
         * aria-valuenow/style bindings evaluate for the first time.
         */
        initResize({ resizable, resizeMin, resizeMax, resizeKey }) {
            this.resizable = Boolean(resizable);
            if (resizeMin) this.resizeMin = resizeMin;
            if (resizeMax) this.resizeMax = resizeMax;
            this.resizeKey = resizeKey || null;

            if (!this.resizeKey) return;
            try {
                const stored = parseInt(window.localStorage.getItem(this.resizeKey) ?? '', 10);
                if (Number.isFinite(stored)) {
                    this.resizeWidth = this.clampResizeWidth(stored);
                }
            } catch {
                /* Blocked storage: keep the default width. */
            }
        },

        clampResizeWidth(width) {
            const viewportCeiling = typeof window === 'undefined'
                ? this.resizeMax
                : Math.max(this.resizeMin, window.innerWidth - 48);
            const ceiling = Math.min(this.resizeMax, viewportCeiling);
            // Whole pixels only: a subpixel width (e.g. from a measured
            // getBoundingClientRect()) would make aria-valuenow report a
            // value the rendered layout can't exactly reproduce.
            return Math.round(Math.max(this.resizeMin, Math.min(ceiling, width)));
        },

        setResizeWidth(width) {
            this.resizeWidth = this.clampResizeWidth(width);
            if (!this.resizeKey) return;
            try {
                window.localStorage.setItem(this.resizeKey, String(this.resizeWidth));
            } catch {
                /* Blocked storage: the width still applies for this page. */
            }
        },

        /**
         * Keyboard resize on the handle. Right/Up always grow the value and
         * Left/Down always shrink it — the separator represents a width
         * *value* (its aria-valuenow), so it follows the same convention as a
         * native range input rather than a screen-direction mapping that
         * would have to flip per `side` and under `dir="rtl"`.
         */
        resizeBy(delta) {
            const current = this.resizeWidth ?? this.$refs.panel?.getBoundingClientRect().width ?? this.resizeMin;
            this.setResizeWidth(current + delta);
        },

        /**
         * Drag the panel's free edge. Uses the pointer's absolute position
         * against whichever physical edge of the panel is NOT pinned to the
         * viewport, so it is correct for either side and under RTL without
         * tracking direction explicitly.
         */
        startResize(event) {
            event.preventDefault();
            const panel = this.$refs.panel;
            if (!panel) return;

            const rect = panel.getBoundingClientRect();
            const pinnedStart = rect.left <= window.innerWidth - rect.right;

            this.resizing = true;
            this._onResizeMove = (ev) => {
                const width = pinnedStart ? ev.clientX - rect.left : rect.right - ev.clientX;
                this.resizeWidth = this.clampResizeWidth(width);
            };
            this._onResizeUp = () => {
                window.removeEventListener('pointermove', this._onResizeMove);
                window.removeEventListener('pointerup', this._onResizeUp);
                this.resizing = false;
                if (this.resizeWidth !== null) {
                    this.setResizeWidth(this.resizeWidth);
                }
            };

            window.addEventListener('pointermove', this._onResizeMove);
            window.addEventListener('pointerup', this._onResizeUp);
        },

        destroy() {
            window.clearTimeout(this._focusRestoreTimer);
            window.removeEventListener('pointermove', this._onResizeMove);
            window.removeEventListener('pointerup', this._onResizeUp);
            // Release the locks if the overlay is unmounted while still open
            // (Livewire re-render, wire:navigate, conditional @if).
            if (this.open) {
                this.unlockScroll();
                this.unlockInert();
            }
        },

        show() {
            window.clearTimeout(this._focusRestoreTimer);
            this.previouslyFocused = overlayReturnFocusTarget(this.$refs.trigger);
            this.open = true;
            this.lockScroll();
            this.lockInert();
            this.$nextTick(() => {
                this.focusInitial();

                // Seed the separator's aria-valuenow from the panel's actual
                // rendered width (the `size` prop's starting width) the first
                // time a resizable sheet opens, so it never announces a value
                // that doesn't match what's on screen. `size="full"` is
                // excluded from `resizable` in content.blade.php precisely so
                // this can't clamp a full-bleed panel down to resizeMax the
                // instant it opens.
                if (this.resizable && this.resizeWidth === null) {
                    const rect = this.$refs.panel?.getBoundingClientRect();
                    if (rect) {
                        this.resizeWidth = this.clampResizeWidth(rect.width);
                    }
                }
            });
        },

        hide() {
            if (!this.open) {
                return;
            }
            this.open = false;
            this.unlockScroll();
            this.unlockInert();
            const previous = this.previouslyFocused;
            const target =
                previous instanceof HTMLElement &&
                previous !== document.body &&
                previous !== document.documentElement
                    ? previous
                    : this.$refs.trigger;
            if (target) {
                this.$nextTick(() => {
                    target.focus({ preventScroll: true });

                    // WebKit can move focus back to the leaving panel while it
                    // finishes the exit transition. Restore it again after the
                    // 300 ms transition so every browser has the same result.
                    this._focusRestoreTimer = window.setTimeout(() => {
                        if (!this.open && target.isConnected) {
                            target.focus({ preventScroll: true });
                        }
                    }, 350);
                });
            }
        },

        focusables() {
            return overlayFocusables(this.$refs.panel);
        },

        focusInitial() {
            focusOverlayInitial(this.$refs.panel);
        },

        /** Tab is always prevented in markup; we move focus manually. */
        trap(event) {
            trapOverlayFocus(this.$refs.panel, event);
        },
    }));
});

Ownership & lifecycle

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