Skip to content

Modals

Modal::make($id)->title('…')->schema([...]) declares a dialog. It never sits in the page tree on its own — a single host renders it, reached one of two ways.

Pass a Modal to ->modal() on Button, Link, or MenuItem. Clicking the trigger opens it — no id to wire up, no effect to dispatch:

use Lattice\Ui\Components\Button;
use Lattice\Ui\Components\Modal;
Button::make('Invite')
->modal(
Modal::make('invite-member')
->title('Invite a member')
->description('They will receive an email invitation.')
->schema([
// form fields, text, buttons…
]),
);

->modal() also accepts a closure returning a Modal, resolved like any other closure parameter at serialization time — reach for it when the modal’s content depends on data that is expensive to build up front, or should be re-evaluated per render:

Button::make('Details')->modal(fn (): Modal => Modal::make('order-details')
->title("Order #{$this->order->id}")
->schema([...]));

A clickable component carries exactly one behavior: an href, an action, effects, or a modal. Setting a second one throws — Button::make('Details')->href('/x')->modal(...) is rejected the same way ->href()->action() is.

An action can ship a modal instead of embedding one on a trigger — the modal travels in the open-modal effect’s payload:

use Lattice\Actions\ActionResult;
use Lattice\Ui\Components\Modal;
return ActionResult::success()->openModal(
Modal::make('order-details')
->title('Order details')
->schema([...]),
);

This is the right shape when the modal’s content depends on server-side work the action just did — looking up a record, rendering a generated document — rather than data already available at the trigger.

For a modal that should be open on page load, flash the same effect from a controller or middleware instead of embedding it in the tree, with Effects::flash():

use Lattice\Facades\Effects;
use Lattice\Ui\Components\Modal;
Effects::flash(Effects::openModal(
Modal::make('welcome')->title('Welcome back')->schema([...]),
));
return redirect('/dashboard');

->closeModal($id) closes the hosted modal if it matches $id; ->closeModal() with no argument closes every modal currently open, not just the topmost one.

  • ->title('…') and ->description('…') set the dialog header.
  • ->closeLabel('…') relabels the close button (defaults to Close).
  • ->slideOut() docks the dialog to a viewport edge as a full-height sheet; ->slideOut(Side::Start) picks the leading edge instead of the trailing one.
  • ->width(ModalWidth::…) sets the dialog width — the max width of a centered dialog and the panel width of a sheet. Defaults to ModalWidth::Lg (32rem).

->slideOut() presents the dialog as a full-height sheet docked to a viewport edge instead of a centered window — the pattern for quick previews and edits alongside a table. The default edge is the trailing one; pass a Side to dock it to the leading edge.

use Lattice\Ui\Enums\ModalWidth;
use Lattice\Ui\Enums\Side;
Modal::make('order-preview')
->title('Order #1042')
->slideOut()
->width(ModalWidth::Xl2)
->schema([
// …
]);
Modal::make('saved-filters')
->title('Saved filters')
->slideOut(Side::Start)
->schema([
// …
]);

Sheets and centered dialogs share the same width scale: ->width() caps a centered dialog and sets a sheet’s panel width.

Opening a modal while another is already open — a button’s ->modal() inside a modal’s schema, or a row action’s confirmation dialog above a modal that contains its table — pushes a new entry onto the host rather than replacing the current one. Closing the top entry (Escape or its close button) reveals whatever was open underneath; ->closeModal() with no id closes the entire stack instead of just the topmost entry.

The host itself is cleared on real page navigation — an open modal does not survive a redirect or a link to another page, since its content could otherwise hold stale props from the page it was opened on. A partial reload (->reloadComponent(), polling, an action’s own table refresh) does not count as navigation and leaves an open modal untouched.

A modal’s schema can include a Fragment::lazy(...) — its content fetches from the server the first time the modal opens, not when the trigger renders, so a rarely-opened modal never pays for data the page doesn’t need:

use Lattice\Fragments\Components\Fragment;
Button::make('Activity')
->modal(
Modal::make('activity-log')
->title('Activity log')
->schema([
Fragment::lazy(ActivityLogFragment::class),
]),
);

See Fragments for how Fragment::lazy() works.