Skip to content

Overlays & State ​

Four Blade components cover the interaction vocabulary a storefront section keeps rebuilding: a sheet (modal bottom sheet or side drawer), an empty state, a pill group, and a loading skeleton. Like the carousel, they live in the filamentcraft:: namespace, are styled entirely from your theme's tokens in the package's site.css, use logical CSS properties (RTL needs nothing extra), respect prefers-reduced-motion, and never rely on Tailwind utilities your host build might not ship.

blade
<x-filamentcraft::sheet id="course-plan" :title="$course->title" size="lg">
    …
    <x-slot:footer>…</x-slot:footer>
</x-filamentcraft::sheet>

<x-filamentcraft::empty-state icon="heroicon-o-magnifying-glass" :title="__('No courses yet')" :hint="__('Try another branch.')" />
<x-filamentcraft::pills name="branch" :options="$branches" wire:model.live="branchId" />
<x-filamentcraft::skeleton lines="3" />

Sheet ​

A modal panel that renders as a bottom sheet with a drag handle on phones, and as a docked bottom sheet (side="bottom") or a full-height drawer on the inline-end edge (side="end") from 640px up.

blade
<button type="button" data-fc-sheet-open="size-guide" aria-haspopup="dialog">
    Size guide
</button>

<x-filamentcraft::sheet id="size-guide" title="Size guide" size="md" side="end">
    <table>…</table>

    <x-slot:footer>
        <button type="button" class="fc-btn fc-btn-primary" data-fc-sheet-close>Got it</button>
    </x-slot:footer>
</x-filamentcraft::sheet>

Props ​

PropDefaultWhat it does
idrequiredDOM id of the <dialog>. Triggers and script events address the sheet by it.
titlenullVisible heading; the dialog is aria-labelledby it. Without a title, pass your own aria-label.
size'md'Panel width from 640px up: sm (24rem), md (32rem), lg (44rem).
side'bottom'bottom docks the panel to the bottom edge; end slides a full-height drawer in from the inline end (the right in LTR, the left in RTL). Phones always get a bottom sheet.
localeapp localeLocale for the close button's label.
close-labeltranslatedOverrides the close button's aria-label.

The default slot is the scrollable body; the optional footer slot is pinned below it and accepts attributes (<x-slot:footer class="…">). Any other attribute lands on the <dialog>.

On phones the panel is capped at min(92dvh, 720px) and pads its bottom edge by env(safe-area-inset-bottom), so the footer clears the home indicator.

Opening and closing ​

The behaviour ships in the public site bundle; there is nothing to register.

  • Triggers: any element with data-fc-sheet-open="{id}" opens that sheet. Triggers get aria-expanded kept in sync.
  • Closers: data-fc-sheet-close closes the sheet it sits in, or the sheet named by its value (data-fc-sheet-close="size-guide") from anywhere on the page.
  • Escape and a click on the backdrop close it. A text selection dragged out of the panel does not.
  • Drag handle: on phones, dragging the handle down more than a quarter of the panel's height dismisses the sheet.
  • Focus moves into the sheet on open, stays there while it is open, and returns to the trigger on close. The page behind is inert and does not scroll.

From script or a Livewire component, dispatch a window event with the sheet's id:

php
$this->dispatch('filamentcraft:sheet-open', id: 'course-plan');
$this->dispatch('filamentcraft:sheet-close', id: 'course-plan');
js
window.dispatchEvent(new CustomEvent('filamentcraft:sheet-open', { detail: { id: 'course-plan' } }));

Both a plain { id } detail and Livewire's array-wrapped payload are accepted. The bundle announces state changes as filamentcraft:sheet-opened and filamentcraft:sheet-closed on window, each with { id } in detail, so an Alpine component can react with x-on:filamentcraft:sheet-closed.window="…".

Why a native <dialog>, not Livewire state ​

The sheet is a <dialog> opened with showModal(), and its open state lives in the browser, never in a Livewire property:

  • The top layer. A modal dialog renders above everything, outside any ancestor's stacking context. A transform, filter or backdrop-filter on a sticky header or section wrapper cannot trap it, which is what happens to a position: fixed overlay nested inside one.
  • Accessibility for free. The browser supplies modal semantics, the focus trap, Escape, and inertness for the rest of the page, the parts hand-rolled overlays most often get wrong.
  • Morph safety. A Livewire round-trip re-renders the component and would reset a server-side $open flag, or close the sheet mid-interaction. The dialog carries wire:ignore.self, so a morph updates its contents without stripping open, and the script looks the sheet up by id on every event instead of holding a reference a morph could replace. Put the sheet's content under Livewire's control as usual; leave whether it is open to the browser.

Empty state ​

Plain markup for a listing with nothing in it: an optional icon, a title, a hint, and an optional action. It is static content, so it carries no role="status". If the empty state appears in response to a filter change and should be announced, wrap the results region in your own live region.

blade
<x-filamentcraft::empty-state
    icon="heroicon-o-magnifying-glass"
    :title="__('No courses match')"
    :hint="__('Try another branch or clear the filters.')"
    :action-label="__('Clear filters')"
    :action-url="$clearUrl" />

<x-filamentcraft::empty-state :title="__('Your cart is empty')">
    <x-slot:action>
        <button type="button" class="fc-btn fc-btn-primary" wire:click="browse">Browse courses</button>
    </x-slot:action>
</x-filamentcraft::empty-state>
PropWhat it does
iconAny Blade Icons name (the site already ships Heroicons). Decorative, so aria-hidden.
titleThe main line.
hintSupporting copy, capped at a readable measure.
action-label + action-urlRenders a secondary button link. Ignored when the action slot is given.

The action slot takes any markup (buttons with wire:click, several links); the default slot adds free content between the hint and the action.

Pills ​

Single-select chips built from real <input type="radio"> elements in a role="radiogroup", so the browser provides arrow-key roving focus (mirrored in RTL), form submission under name, and wire:model / x-model binding. The chip is the radio's styled label, so there is no aria-pressed to keep in sync.

blade
<x-filamentcraft::pills
    name="branch"
    :options="$branches->pluck('name', 'id')"
    labelledby="branch-heading"
    wire:model.live="branchId" />
PropWhat it does
nameThe radios' name, submitted with the form.
optionsvalue => label array.
valueThe selected value for a plain form (a backed enum works too). Livewire and Alpine bindings set it themselves.
label / labelledbyAccessible name for the group (aria-label / aria-labelledby). Provide one.

Every wire:model* and x-model* attribute is forwarded onto each radio input rather than the wrapper, because that is where Livewire and Alpine read and write the value. Other attributes stay on the group.

Skeleton ​

Placeholder bones for content that is still loading. It is aria-hidden: announce loading state on the real region (for example aria-busy) rather than on the placeholder.

blade
<div wire:loading.remove wire:target="branchId">
    @foreach ($courses as $course) … @endforeach
</div>

<x-filamentcraft::skeleton lines="3" avatar wire:loading.block wire:target="branchId" />
PropDefaultWhat it does
lines3Number of text lines (at least one). The last is shorter.
avatarfalseAdds a round bone beside the lines.
mediafalseAdds a 16:9 media bone above them.

Use wire:loading.block rather than bare wire:loading, which shows the element as inline-block. The shimmer sweep is switched off under prefers-reduced-motion: reduce.

Styling ​

Everything is plain classes in site.css, driven by the theme's tokens (--fc-color-surface, --fc-color-on-surface, --fc-color-primary, --box-radius, --button-radius):

  • Sheet: .fc-sheet (with --sm/--md/--lg and --bottom/--end), .fc-sheet__panel, __handle, __header, __title, __close, __body, __footer. The width comes from --fc-sheet-width.
  • Empty state: .fc-empty-state, __icon, __title, __hint, __body, __actions.
  • Pills: .fc-pills, .fc-pills__option, __input, __label.
  • Skeleton: .fc-skeleton, __row, __lines, __bone, __line, __avatar, __media.

Translations ​

The sheet's close label comes from filamentcraft::filamentcraft.overlays.close. Publish the package lang files to change it. The other components render only the text you pass them.