Modal Surfaces
The four modal surfaces that compose the shared overlay anatomy — a centred dialog, a confirmation alert dialog, an edge sheet and a bottom drawer.
Preview
Navigation
Browse the sections of your account from here.
<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
Size options
Installation
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:
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.
-
resources/views/components/ui/sheet.blade.php -
resources/views/components/ui/sheet/trigger.blade.php -
resources/views/components/ui/sheet/content.blade.php -
resources/views/components/ui/sheet/header.blade.php -
resources/views/components/ui/sheet/title.blade.php -
resources/views/components/ui/sheet/description.blade.php -
resources/views/components/ui/sheet/footer.blade.php -
resources/views/components/ui/sheet/close.blade.php -
resources/js/ui/sheet.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: 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
{{--
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
<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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="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.
<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.
{{--
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>
<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>
@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>
@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>
{{-- 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>
{{-- 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>
@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>
@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 }} />
// 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