Skip to content
Brok UI

Loading…

No results

Toast

Open source Core

Notifications with one Alpine lifecycle for queued delivery, normalized variants, timed or manual dismissal, animation, and cleanup, plus an optional subject link and one action such as Undo. Listens for a window event (`event`, default `toast`) and renders a session flash (`flash`).

Version
v1.9.0
Stability
stable
License
MIT
Related
Alert Dialog
Status
Alert

Preview

previews.components.toast.default.blade.php Blade
<div x-data class="flex flex-wrap items-center gap-3">
    <x-ui.button @click="window.toast({ title: '{{ __('Saved') }}', message: '{{ __('Your changes have been saved.') }}', variant: 'success' })">
        {{ __('Success toast') }}
    </x-ui.button>

    <x-ui.button variant="destructive" @click="window.toast({ title: '{{ __('Error') }}', message: '{{ __('Something went wrong.') }}', variant: 'destructive' })">
        {{ __('Error toast') }}
    </x-ui.button>

    <x-ui.button variant="outline" @click="window.toast('{{ __('A plain notification.') }}')">
        {{ __('Plain toast') }}
    </x-ui.button>

    <x-ui.button variant="outline" @click="window.toast({ title: '{{ __('Trial ends soon') }}', message: '{{ __('Add a payment method to keep your workspace.') }}', variant: 'warning' })">
        {{ __('Warning toast') }}
    </x-ui.button>

    {{-- Mount the live region once per page (here, scoped to the preview). --}}
    <x-ui.toaster />
</div>

Installation

terminal
php artisan ui:add toast

Note

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

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

Registry contract

php artisan ui:add toast 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/toaster.blade.php
  • js resources/js/ui/toast.js
Registry dependencies
None — installs on its own.
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.

toast.md
# Brok UI: Toast (`toast`)

Notifications with one Alpine lifecycle for queued delivery, normalized variants, timed or manual dismissal, animation, and cleanup, plus an optional subject link and one action such as Undo. Listens for a window event (`event`, default `toast`) and renders a session flash (`flash`).

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

## Install

```bash
php artisan ui:add toast
```

## Usage

```blade
<div x-data class="flex flex-wrap items-center gap-3">
    <x-ui.button @click="window.toast({ title: '{{ __('Saved') }}', message: '{{ __('Your changes have been saved.') }}', variant: 'success' })">
        {{ __('Success toast') }}
    </x-ui.button>

    <x-ui.button variant="destructive" @click="window.toast({ title: '{{ __('Error') }}', message: '{{ __('Something went wrong.') }}', variant: 'destructive' })">
        {{ __('Error toast') }}
    </x-ui.button>

    <x-ui.button variant="outline" @click="window.toast('{{ __('A plain notification.') }}')">
        {{ __('Plain toast') }}
    </x-ui.button>

    <x-ui.button variant="outline" @click="window.toast({ title: '{{ __('Trial ends soon') }}', message: '{{ __('Add a payment method to keep your workspace.') }}', variant: 'warning' })">
        {{ __('Warning toast') }}
    </x-ui.button>

    {{-- Mount the live region once per page (here, scoped to the preview). --}}
    <x-ui.toaster />
</div>
```

## Props

- `position` (string, default `bottom-end`) — Corner or edge the toast stack anchors to: bottom-end, bottom-start, bottom-center, top-end, top-start, or top-center. Bottom-end suits passive notifications; top-center suits an app whose primary actions sit in a top bar.
- `event` (string, default `toast`) — Name of the window CustomEvent the region listens for; a Livewire $this->dispatch('toast', ...) or a plain window.dispatchEvent raises a toast through it. Pass null to opt out.
- `flash` (mixed|null, default `null`) — A server-rendered message for the next page, from session()->flash(); a string, or an array with id, title, message, variant and href. Each render gets its own toast id (or the array's id), so a page started again from a cached snapshot (wire:navigate back) does not show it twice.
- `flashVariant` (string, default `success`) — Variant used for the flash message when it does not specify its own.
- `duration` (int, default `4000`) — Default auto-dismiss time in milliseconds for toasts raised through event or flash; 0 keeps them until manually dismissed.

## Use when

- Use for low-priority transient confirmations that support the current task.
- Showing a brief, passive notification for an action's result, such as "Saved" or an error, without interrupting the page.
- Surfacing a server-side flash message or a Livewire-dispatched notification through one shared queue.
- Offering a short-lived way back from an action that already happened, such as Undo after a decline, archive or move.

## Avoid when

- Do not rely on transient messaging for critical outcomes users must not miss.
- Reserve for low-priority transient feedback rather than outcomes users must reliably notice.
- The message requires the user to make a decision before continuing; use alert-dialog or dialog instead.
- The status is tied to a specific control rather than a page-level event; use status or a field's own inline error text.

## Anti-patterns

- Destructive warnings or validation errors in toasts

## Rules

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

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

Examples

action.blade.php Blade
{{-- An Undo toast. The toast names its subject as a link, and its action
     dispatches a window event the host listens for; a Livewire component
     would use #[On('task-restore')] or pass { method, component } instead. --}}
<div
    x-data="{ declined: false }"
    x-on:task-restore.window="declined = false"
    class="flex flex-col items-start gap-3"
>
    <p class="text-sm text-muted-foreground" data-testid="toast-action-state" x-text="declined ? @js(__('ATL-12 is declined.')) : @js(__('ATL-12 is in triage.'))"></p>
    <x-ui.button
        variant="outline"
        x-on:click="declined = true; window.toast({
            title: {{ \Illuminate\Support\Js::from(__('ATL-12 declined')) }},
            message: {{ \Illuminate\Support\Js::from(__('The task moved to Canceled.')) }},
            href: '#atl-12',
            timeout: 8000,
            action: { label: {{ \Illuminate\Support\Js::from(__('Undo')) }}, event: 'task-restore', detail: { id: 12 } },
        })"
    >
        {{ __('Decline ATL-12') }}
    </x-ui.button>

    <x-ui.toaster :event="null" />
</div>
action-key.blade.php Blade
{{-- An Undo toast with a key hint: the action button shows Z, and pressing
     Z (outside a text field) runs the action of the newest toast that
     offers it. --}}
<div
    x-data="{ archived: false }"
    x-on:item-restore.window="archived = false"
    class="flex flex-col items-start gap-3"
>
    <p class="text-sm text-muted-foreground" data-testid="toast-action-key-state" x-text="archived ? @js(__('The item is archived.')) : @js(__('The item is in the inbox.'))"></p>
    <x-ui.button
        variant="outline"
        x-on:click="archived = true; window.toast({
            message: {{ \Illuminate\Support\Js::from(__('Item archived.')) }},
            timeout: 8000,
            action: { label: {{ \Illuminate\Support\Js::from(__('Undo')) }}, event: 'item-restore', detail: { id: 7 }, key: 'z' },
        })"
    >
        {{ __('Archive item') }}
    </x-ui.button>

    <x-ui.toaster :event="null" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
position string bottom-end Corner or edge the toast stack anchors to: bottom-end, bottom-start, bottom-center, top-end, top-start, or top-center. Bottom-end suits passive notifications; top-center suits an app whose primary actions sit in a top bar.
event string toast Name of the window CustomEvent the region listens for; a Livewire $this->dispatch('toast', ...) or a plain window.dispatchEvent raises a toast through it. Pass null to opt out.
flash mixed | null null A server-rendered message for the next page, from session()->flash(); a string, or an array with id, title, message, variant and href. Each render gets its own toast id (or the array's id), so a page started again from a cached snapshot (wire:navigate back) does not show it twice.
flashVariant string success Variant used for the flash message when it does not specify its own.
duration int 4000 Default auto-dismiss time in milliseconds for toasts raised through event or flash; 0 keeps them until manually dismissed.

Slots

  • default —
  • x-ui.toaster — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

toast toast-action toast-action-key toast-close toast-icon toast-link toaster

Behavior

  • Toasts queue through one shared Alpine lifecycle, so multiple toasts raised in quick succession stack and dismiss independently.
  • Each toast auto-dismisses after duration, unless duration is 0 or the user dismisses it manually with the close button.
  • The region listens for the named window event, so a Livewire dispatch or a plain CustomEvent from anywhere on the page can raise a toast without direct wiring to this component.
  • A flash value is shown once on page load, for server-rendered redirect-back notifications.
  • A toast with an action shows one button before the close button. Activating it runs the event or Livewire method once and closes the toast. Livewire example: $this->dispatch('toast', title: __('ATL-12 declined'), href: route('tasks.show', 12), action: ['label' => __('Undo'), 'event' => 'task-restore', 'detail' => ['id' => 12]]).
  • The auto-dismiss timer pauses while the pointer or keyboard focus is inside a toast and resumes (at least 1.5 seconds) when both leave, so a link or an action can be reached in time.
  • Escape inside a toast dismisses that toast only; the event does not reach a dialog behind it.
  • $store.toast.lastHref() returns the link of the newest toast that has one, for an "open the last toast" shortcut.
  • A bottom stack sits on the --ui-action-bar-inset custom property (0px when unset). An open bottom or sticky action-bar sets it on <html>, so toasts stack above the bar and never cover its Clear or actions, also on a phone where both span the bottom edge (WCAG 2.4.11).
  • Action key (action.key): the action button shows the key as a kbd hint and exposes it through aria-keyshortcuts. One window keydown listener runs the newest visible toast whose action has that key, then the toast closes, as a click does. A handled key (defaultPrevented) is skipped, so an app shortcut on the same key does not run twice.
  • With the hotkeys item installed (optional; the toaster works without it), the key of the newest visible toast is registered in the hotkeys registry while that toast is visible: the ? cheat sheet lists it with the action label under the toaster region's name (Notifications) and drops it when the toast closes. It is not listed while an app shortcut is registered on the same key (that shortcut keeps the key), and the single-key shortcuts switch turns the toast key off too.
  • Once per event: Livewire 4 sends a nested component's last dispatched events again when its parent renders, so a toast raised in a nested component can arrive twice. Give each server toast a dedupe key unique to the event — $this->dispatch('toast', dedupe: (string) Str::uuid(), title: __('Saved'), variant: 'success') — and the repeat is ignored. Dispatch the toast from the component that owns the action, not again from a parent or a child.
  • 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

Transient feedback

Confirm a low-priority outcome transiently.

Use when

  • Use for low-priority transient confirmations that support the current task.
  • Showing a brief, passive notification for an action's result, such as "Saved" or an error, without interrupting the page.
  • Surfacing a server-side flash message or a Livewire-dispatched notification through one shared queue.
  • Offering a short-lived way back from an action that already happened, such as Undo after a decline, archive or move.

Avoid when

  • Do not rely on transient messaging for critical outcomes users must not miss.
  • Reserve for low-priority transient feedback rather than outcomes users must reliably notice.
  • The message requires the user to make a decision before continuing; use alert-dialog or dialog instead.
  • The status is tied to a specific control rather than a page-level event; use status or a field's own inline error text.

Use instead

  • Inline alert
  • Persistent banner for important outcomes

Anti-patterns

  • Destructive warnings or validation errors in toasts
Anatomy
root toaster toast-icon toast-link toast-action toast-close
Theming hooks
toast toast-action toast-action-key toast-close toast-icon toast-link toaster

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
native
  • Toasts announce through a live region, so screen readers hear new notifications without needing focus to move to them.
  • Each toast provides a real close button in addition to its timed auto-dismiss, so users are not forced to catch it before it disappears.
  • The action and the link are real buttons and anchors in the Tab order, before the close button; the region stays aria-live polite (warning and destructive toasts stay assertive), so an Undo toast never interrupts.
  • Keep an action optional and repeatable elsewhere: a toast can close before a screen-reader user reaches it, so Undo must not be the only way to reverse the change (WCAG 2.2.1).
  • 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="toast-{{ $record->id }}">
    <div x-data class="flex flex-wrap items-center gap-3">
        <x-ui.button @click="window.toast({ title: '{{ __('Saved') }}', message: '{{ __('Your changes have been saved.') }}', variant: 'success' })">
            {{ __('Success toast') }}
        </x-ui.button>
    
        <x-ui.button variant="destructive" @click="window.toast({ title: '{{ __('Error') }}', message: '{{ __('Something went wrong.') }}', variant: 'destructive' })">
            {{ __('Error toast') }}
        </x-ui.button>
    
        <x-ui.button variant="outline" @click="window.toast('{{ __('A plain notification.') }}')">
            {{ __('Plain toast') }}
        </x-ui.button>
    
        <x-ui.button variant="outline" @click="window.toast({ title: '{{ __('Trial ends soon') }}', message: '{{ __('Add a payment method to keep your workspace.') }}', variant: 'warning' })">
            {{ __('Warning toast') }}
        </x-ui.button>
    
        {{-- Mount the live region once per page (here, scoped to the preview). --}}
        <x-ui.toaster />
    </div>
</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:toast
        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/toaster.blade.php Blade
@props([
    // bottom-end (default) | bottom-start | bottom-center | top-end | top-start | top-center.
    // Bottom-end is the convention for passive notifications (out of the reading
    // path, near the actions that caused them); top-center suits an app whose
    // primary actions sit in a top bar. Pick one per app and keep it.
    'position' => 'bottom-end',
    // Name of the window event the region listens for. Livewire's
    // `$this->dispatch('toast', title: 'Saved', variant: 'success')` reaches
    // it as a CustomEvent whose detail carries the named arguments, and plain
    // JS can `window.dispatchEvent(new CustomEvent('toast', { detail: {…} }))`.
    // No Livewire code is involved, so the listener is inert without it.
    // Pass null to opt out.
    'event' => 'toast',
    // A server-rendered message for the next page: `session()->flash('status',
    // 'Saved.')` then `:flash="session('status')"`. A string, or
    // ['id' => …, 'title' => …, 'message' => …, 'variant' => …, 'href' => …].
    // Each render gets its own toast id (or the array's `id`), so a page that
    // starts again from a cached snapshot (wire:navigate back) does not show
    // the same flash twice.
    'flash' => null,
    'flashVariant' => 'success',
    // Default auto-dismiss (ms) for toasts raised through `event` or `flash`;
    // 0 keeps them until dismissed. `window.toast()` callers pass their own.
    'duration' => 4000,
])

@php
    $flashPayload = null;
    if (is_array($flash) ? filled($flash['message'] ?? $flash['title'] ?? null) : filled($flash)) {
        $flashPayload = [
            'dedupe' => is_array($flash) && filled($flash['dedupe'] ?? null) ? (string) $flash['dedupe'] : 'flash-'.\Illuminate\Support\Str::random(16),
            'title' => is_array($flash) ? (string) ($flash['title'] ?? '') : '',
            'message' => is_array($flash) ? (string) ($flash['message'] ?? '') : (string) $flash,
            'variant' => is_array($flash) ? (string) ($flash['variant'] ?? $flashVariant) : (string) $flashVariant,
            'timeout' => max(0, (int) $duration),
            'href' => is_array($flash) ? ($flash['href'] ?? null) : null,
        ];
    }
    // Dots would read as Alpine modifiers, so the name is limited to a plain token.
    $eventName = filled($event) ? preg_replace('/[^a-zA-Z0-9:_-]/', '', (string) $event) : null;
    // Toasts travel along the edge they live on: top-anchored stacks slide down
    // from above the viewport edge, bottom-anchored stacks slide up from below —
    // and leave back the same way. Percentage travel scales with toast height.
    $linkClasses = 'rounded-sm text-foreground underline decoration-border underline-offset-4 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none';
    $offscreen = str_starts_with($position, 'top') ? 'motion-safe:-translate-y-full' : 'motion-safe:translate-y-full';
@endphp

{{--
    Toaster (spec §17 toast live-region behavior + §19 behavior layer).
    Place once near the end of your layout. Push toasts from anywhere with the
    global helper: window.toast('Saved') or window.toast({ dedupe, title, message,
    variant: 'success'|'destructive'|'warning'|'default', timeout, href,
    action: { label, event, detail, key } | { label, method, params, component, key } }).
    `dedupe` shows a toast once per page: a repeat with the same key (Livewire 4
    sends a nested component's last events again when its parent renders) is
    ignored. Give each event its own key, such as a UUID.
    `action.key` (for example 'z') shows a key hint on the action button and
    runs the action when that key is pressed outside a text field.
    Legacy
    `error` and `info` variants normalize to `destructive` and `default`.

    The region is aria-live=polite so screen readers announce new toasts without
    stealing focus. Urgent toasts (destructive/error) escalate to an assertive
    role="alert" so they interrupt. Each toast can be dismissed with its close
    button.
--}}
{{-- Its own Alpine root: the region is placed at the end of a layout, usually
     outside any x-data, and the x-for below needs a scope to run in. --}}
<div
    data-slot="toaster"
    x-data
    role="region"
    aria-label="{{ __('Notifications') }}"
    aria-live="polite"
    aria-relevant="additions"
    data-position="{{ $position }}"
    @if ($eventName)
        {{-- Livewire dispatches and plain CustomEvents both land here. A
             positional payload (`dispatch('toast', 'Saved')`) arrives as an
             array; named arguments arrive as an object. --}}
        x-on:{{ $eventName }}.window="$store.toast.push(Array.isArray($event.detail) ? ($event.detail[0] ?? {}) : ($event.detail ?? {}), {{ max(0, (int) $duration) }})"
    @endif
    @if ($flashPayload)
        x-init="$store.toast.push({{ \Illuminate\Support\Js::from($flashPayload) }})"
    @endif
    {{-- On a phone the stack spans the bottom (or top) edge: a 24rem column
         pinned to one corner would hug the edge and clip. A bottom stack
         sits on --ui-action-bar-inset, which an open action bar sets, so a
         toast never covers the bar's Clear or actions (WCAG 2.4.11). --}}
    {{ $attributes->class([
        'pointer-events-none fixed z-toast flex w-full flex-col gap-2 p-4 max-sm:inset-x-0 sm:max-w-sm',
        'bottom-[var(--ui-action-bar-inset,0px)] sm:end-0' => $position === 'bottom-end',
        'bottom-[var(--ui-action-bar-inset,0px)] sm:start-0' => $position === 'bottom-start',
        'bottom-[var(--ui-action-bar-inset,0px)] sm:start-1/2 sm:-translate-x-1/2 rtl:sm:translate-x-1/2' => $position === 'bottom-center',
        'top-0 sm:end-0' => $position === 'top-end',
        'top-0 sm:start-0' => $position === 'top-start',
        'top-0 sm:start-1/2 sm:-translate-x-1/2 rtl:sm:translate-x-1/2' => $position === 'top-center',
    ]) }}
>
    <template x-for="toast in $store.toast.items" :key="toast.id">
        <div
            data-slot="toast"
            :data-variant="toast.variant"
            {{-- Urgent variants interrupt; others are announced politely. The
                 per-toast live region overrides the polite region wrapper. --}}
            :role="['destructive', 'warning'].includes(toast.variant) ? 'alert' : 'status'"
            :aria-live="['destructive', 'warning'].includes(toast.variant) ? 'assertive' : 'polite'"
            x-show="toast.visible"
            x-transition:enter="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out"
            x-transition:enter-start="opacity-0 {{ $offscreen }}"
            x-transition:enter-end="opacity-100 motion-safe:translate-y-0"
            x-transition:leave="motion-safe:transition motion-safe:duration-150 motion-safe:ease-in"
            x-transition:leave-start="opacity-100 motion-safe:translate-y-0"
            x-transition:leave-end="opacity-0 {{ $offscreen }}"
            {{-- One card surface for every variant; the tone lives in the icon
                 and the title (the alert's language), so a green or red slab
                 never has to be read. --}}
            {{-- The timer holds while the pointer or focus is inside, so an
                 action or link can be reached before the toast goes. --}}
            @mouseenter="$store.toast.pause(toast.id)"
            @mouseleave="$el.contains(document.activeElement) || $store.toast.resume(toast.id)"
            @focusin="$store.toast.pause(toast.id)"
            @focusout="$el.contains($event.relatedTarget) || $el.matches(':hover') || $store.toast.resume(toast.id)"
            @keydown.escape.stop="$store.toast.dismiss(toast.id)"
            class="pointer-events-auto flex items-start gap-3 rounded-md border border-border bg-card p-4 text-card-foreground shadow-lg motion-reduce:transition-none"
        >
            <span
                data-slot="toast-icon"
                aria-hidden="true"
                class="mt-px shrink-0"
                :class="{
                    'text-muted-foreground': toast.variant === 'default',
                    'text-success-text': toast.variant === 'success',
                    'text-destructive-text': toast.variant === 'destructive',
                    'text-warning-text': toast.variant === 'warning',
                }"
            >
                <svg x-show="toast.variant === 'default'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 16v-4M12 8h.01" /></svg>
                <svg x-show="toast.variant === 'success'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></svg>
                <svg x-show="toast.variant === 'warning'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.7 18-8-14a2 2 0 0 0-3.4 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.7-3" /><path d="M12 9v4M12 17h.01" /></svg>
                <svg x-show="toast.variant === 'destructive'" class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6M9 9l6 6" /></svg>
            </span>
            <div class="min-w-0 flex-1 space-y-1">
{{-- With `href` the toast names its subject as a link: the title,
                     or the message when there is no title. Links keep the
                     foreground colour and show an underline. --}}
                <template x-if="toast.title && !toast.href">
                    <p
                        class="text-sm font-medium"
                        :class="{
                            'text-foreground': toast.variant === 'default',
                            'text-success-text': toast.variant === 'success',
                            'text-destructive-text': toast.variant === 'destructive',
                            'text-warning-text': toast.variant === 'warning',
                        }"
                        x-text="toast.title"
                    ></p>
                </template>
                <template x-if="toast.title && toast.href">
                    <p class="text-sm font-medium">
                        <a data-slot="toast-link" :href="toast.href" class="{{ $linkClasses }}" x-text="toast.title"></a>
                    </p>
                </template>
                <template x-if="!toast.title && toast.href">
                    <p class="text-sm text-muted-foreground">
                        <a data-slot="toast-link" :href="toast.href" class="{{ $linkClasses }}" x-text="toast.message"></a>
                    </p>
                </template>
                <p x-show="toast.title || !toast.href" class="text-sm text-muted-foreground" x-text="toast.message"></p>
            </div>
            {{-- One optional action (Undo). It sits before the close button in
                 the reading and Tab order, runs its event or Livewire method,
                 then closes the toast. --}}
            <template x-if="toast.action">
                <button
                    type="button"
                    data-slot="toast-action"
                    class="-my-1 inline-flex h-7 shrink-0 items-center gap-2 rounded-sm border border-input bg-background px-2 text-sm font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                    :aria-keyshortcuts="toast.action.key ? toast.action.key.toUpperCase() : null"
                    @click="$store.toast.act(toast.id)"
                >
                    <span x-text="toast.action.label"></span>
                    {{-- The key that runs the action (payload action.key). The
                         button's aria-keyshortcuts carries it for assistive
                         technology, so the hint itself is decorative. --}}
                    <template x-if="toast.action.key">
                        <kbd data-slot="toast-action-key" aria-hidden="true" class="inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-border bg-muted px-1 font-mono text-xs text-muted-foreground" x-text="toast.action.key.toUpperCase()"></kbd>
                    </template>
                </button>
            </template>
            <button
                type="button"
                data-slot="toast-close"
                class="-me-1 -mt-1 inline-flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
                @click="$store.toast.dismiss(toast.id)"
            >
                <span class="sr-only">{{ __('Dismiss') }}</span>
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
                    <path d="M18 6 6 18M6 6l12 12" />
                </svg>
            </button>
        </div>
    </template>
</div>
resources/js/ui/toast.js JS
/**
 * Toast store + helper (spec §17 toast live-region behavior + §19).
 *
 * Registers an Alpine store that the <x-ui.toaster> renders, plus a global
 * window.toast() helper you can call from anywhere:
 *
 *   window.toast('Saved')
 *   window.toast({ title: 'Error', message: 'Try again', variant: 'destructive' })
 *
 * Toasts auto-dismiss after `timeout` ms (set timeout: 0 to keep until closed).
 * The timer pauses while the pointer or focus is inside the toast.
 *
 * Optional parts:
 *   href   — the toast names its subject as a link (the title, else the message).
 *   action — one button, such as Undo: { label, event, detail } dispatches a
 *            window CustomEvent (a Livewire #[On] listener receives it), and
 *            { label, method, params, component } calls a method on the
 *            Livewire component with that id. The toast closes after the action.
 *            `key` (one letter or digit, such as 'z') shows a key hint on the
 *            button and runs the newest visible toast's action when that key
 *            is pressed with no modifier, outside a text field and outside an
 *            open modal; a keydown that another handler already handled
 *            (preventDefault) is left alone, so an app shortcut on the same
 *            key never runs twice.
 *
 * Once per event: a payload `dedupe` key (a string or number, unique per
 * event, such as a UUID the server made for this action) shows that toast
 * once. A repeat with the same key is ignored for the rest of the page,
 * whether the first toast is still shown or already closed, and push()
 * returns the first toast's handle while it is shown (else null). Livewire 4
 * sends a nested component's last events again when its parent renders, so
 * give every server toast a key: `$this->dispatch('toast', dedupe: (string)
 * Str::uuid(), title: 'Saved')`. Without one every push shows, as before.
 *
 * With the hotkeys item installed, the key of the newest visible toast is
 * also registered in the hotkeys registry while that toast is visible, so the
 * `?` cheat sheet lists it (under the toaster region's name, "Notifications")
 * and drops it when the toast closes. It is not listed while an app shortcut
 * already uses the same key, and it follows the single-key shortcuts switch.
 * Without hotkeys nothing of this runs.
 */
document.addEventListener('alpine:init', () => {
    // The leave transition is 150ms; the element is removed a little after it ends.
    const LEAVE_MS = 200;

    const normalizeVariant = (variant) => {
        if (variant === 'error') return 'destructive';
        if (variant === 'info') return 'default';

        return ['default', 'success', 'destructive', 'warning'].includes(variant)
            ? variant
            : 'default';
    };

    const safeHref = (href) => {
        if (typeof href !== 'string' || href.trim() === '') return '';
        // Only web and relative links; a javascript: URL never becomes a link.
        return /^\s*(javascript|data|vbscript):/i.test(href) ? '' : href.trim();
    };

    const normalizeAction = (action) => {
        if (!action || typeof action !== 'object') return null;
        const label = String(action.label ?? '').trim();
        const event = typeof action.event === 'string' ? action.event.trim() : '';
        const method = typeof action.method === 'string' ? action.method.trim() : '';
        if (label === '' || (event === '' && method === '')) return null;
        const key = typeof action.key === 'string' && /^[a-z0-9]$/i.test(action.key.trim()) ? action.key.trim().toLowerCase() : '';

        return {
            label,
            key,
            event,
            detail: action.detail ?? {},
            method,
            params: Array.isArray(action.params) ? action.params : action.params === undefined ? [] : [action.params],
            component: action.component ?? null,
        };
    };

    /**
     * Livewire 3 and 4 return the component's $wire proxy from find(). That
     * proxy answers every unknown property with a server-call function, so
     * `found.$wire` and `found.call` are never a safe test: they would call a
     * method named "$wire" or run Function.prototype.call. Use `$call`, which
     * the proxy defines, and fall back to the Livewire 2 component object.
     */
    const callLivewireMethod = (componentId, method, params) => {
        if (!componentId) return;
        const found = window.Livewire?.find?.(componentId);
        if (!found) return;
        if (typeof found.$call === 'function') {
            found.$call(method, ...params);
        } else if (typeof found.call === 'function') {
            found.call(method, ...params);
        }
    };

    /**
     * The hotkeys registry, when the hotkeys item is installed. Read on use,
     * so the order in which the two items start does not matter. The store
     * name is a variable on purpose: loaders map `Alpine.store('<name>'` with a
     * literal name to the module that registers that store.
     */
    const HOTKEYS_STORE = 'hotkeys';
    const hotkeysRegistry = () => {
        const registry = window.Alpine.store(HOTKEYS_STORE);

        return registry && typeof registry.register === 'function' ? registry : null;
    };

    const TOAST_HOTKEY_PREFIX = 'toast-action-';

    /** The newest visible toast whose action runs on this key. */
    const newestWithKey = (key) => {
        const items = window.Alpine.store('toast').items;
        for (let i = items.length - 1; i >= 0; i--) {
            if (items[i].visible && items[i].action?.key === key) return items[i];
        }

        return null;
    };

    /** True while an app shortcut (not a toast) is registered on this plain key. */
    const keyTakenByApp = (registry, key) =>
        registry.bindings.some((binding) => {
            const [step, next] = binding.steps ?? [];

            return !String(binding.id).startsWith(TOAST_HOTKEY_PREFIX)
                && step && !next && step.key === key
                && !step.meta && !step.ctrl && !step.alt && !step.shift
                && registry.inactiveReason(binding) !== 'unavailable';
        });

    const hotkeyHandles = {};

    // Payload dedupe keys already shown on this page (key → toast handle), oldest
    // first. Bounded, so a long-lived page does not grow it without limit.
    const SEEN_LIMIT = 500;
    const seen = new Map();

    const dedupeKey = (config) => {
        const raw = config.dedupe;
        if (raw === undefined || raw === null || (typeof raw !== 'string' && typeof raw !== 'number')) return null;
        const key = String(raw).trim();

        return key === '' ? null : key;
    };

    /** List the toast's action key in the hotkeys cheat sheet while it is visible. */
    const listHotkey = (toast) => {
        const key = toast.action?.key;
        const registry = hotkeysRegistry();
        if (!key || !registry || hotkeyHandles[toast.id]) return;
        const group = document.querySelector('[data-slot="toaster"]')?.getAttribute('aria-label') || 'Notifications';
        hotkeyHandles[toast.id] = registry.register(key, () => {
            const newest = newestWithKey(key);
            if (newest) window.Alpine.store('toast').act(newest.id);
        }, {
            id: `${TOAST_HOTKEY_PREFIX}${toast.id}`,
            description: toast.action.label,
            group,
            scope: 'global',
            enabled: () => newestWithKey(key)?.id === toast.id && !keyTakenByApp(registry, key),
        });
    };

    const unlistHotkey = (id) => {
        hotkeyHandles[id]?.unregister();
        delete hotkeyHandles[id];
    };

    const store = {
        items: [],
        seq: 0,
        timers: {},
        removalTimers: {},
        clearTimer: null,

        /**
         * `options` is a message string or { id, title, message, variant,
         * timeout, href, action }. A repeated `id` is ignored (see above).
         * `fallbackTimeout` applies when the payload carries no timeout (the
         * toaster's `duration` for server-raised toasts).
         */
        push(options, fallbackTimeout = 4000) {
            const config = typeof options === 'string' ? { message: options } : options || {};
            const key = dedupeKey(config);
            if (key !== null && seen.has(key)) {
                const first = seen.get(key);

                return this.items.some((t) => t.id === first && !t.closed) ? first : null;
            }
            const id = ++this.seq;
            if (key !== null) {
                seen.set(key, id);
                if (seen.size > SEEN_LIMIT) seen.delete(seen.keys().next().value);
            }

            const requestedTimeout = Number(config.timeout ?? fallbackTimeout);
            const toast = {
                id,
                key,
                title: config.title ?? '',
                message: config.message ?? '',
                variant: normalizeVariant(config.variant ?? 'default'),
                timeout: Number.isFinite(requestedTimeout) ? Math.max(0, requestedTimeout) : 4000,
                href: safeHref(config.href),
                action: normalizeAction(config.action),
                // Starts hidden: x-show runs no transition on its first value,
                // so the toast is shown on the next tick to play its enter.
                visible: false,
                closed: false,
                remaining: 0,
                startedAt: 0,
            };

            this.items.push(toast);
            const entry = this.items[this.items.length - 1];
            window.Alpine.nextTick(() => {
                if (!entry.closed && this.items.includes(entry)) entry.visible = true;
            });

            this.schedule(toast);
            listHotkey(toast);

            return id;
        },

        schedule(toast, delay = toast.timeout) {
            if (toast.timeout === 0) return;

            toast.remaining = delay;
            toast.startedAt = Date.now();
            this.timers[toast.id] = window.setTimeout(() => this.dismiss(toast.id), delay);
        },

        /** Hold the timer while the pointer or focus is inside the toast. */
        pause(id) {
            const toast = this.items.find((t) => t.id === id);
            if (!toast || !this.timers[id]) return;

            window.clearTimeout(this.timers[id]);
            delete this.timers[id];
            toast.remaining = Math.max(0, toast.remaining - (Date.now() - toast.startedAt));
        },

        resume(id) {
            const toast = this.items.find((t) => t.id === id);
            if (!toast || toast.closed || toast.timeout === 0 || this.timers[id]) return;

            // Give the reader a moment after leaving before it goes.
            this.schedule(toast, Math.max(toast.remaining, 1500));
        },

        /** Run the toast's action (Undo), then close the toast. */
        act(id) {
            const toast = this.items.find((t) => t.id === id);
            const action = toast?.action;
            if (!action) return;

            if (action.event !== '') {
                window.dispatchEvent(new CustomEvent(action.event, { detail: action.detail, bubbles: true }));
            }
            if (action.method !== '') callLivewireMethod(action.component, action.method, action.params);

            this.dismiss(id);
        },

        /** The link of the newest toast that has one (for an "open last toast" shortcut). */
        lastHref() {
            for (let i = this.items.length - 1; i >= 0; i--) {
                if (this.items[i].href) return this.items[i].href;
            }

            return '';
        },

        dismiss(id) {
            const toast = this.items.find((t) => t.id === id);
            if (!toast || toast.closed) return;

            window.clearTimeout(this.timers[id]);
            delete this.timers[id];
            unlistHotkey(id);

            // Animate out, then remove from the list once the 150ms leave is over.
            toast.closed = true;
            toast.visible = false;
            this.removalTimers[id] = window.setTimeout(() => {
                this.items = this.items.filter((t) => t.id !== id);
                delete this.removalTimers[id];
            }, LEAVE_MS);
        },

        clear({ immediate = false } = {}) {
            window.clearTimeout(this.clearTimer);
            this.clearTimer = null;
            Object.values(this.timers).forEach((timer) => window.clearTimeout(timer));
            Object.values(this.removalTimers).forEach((timer) => window.clearTimeout(timer));
            this.timers = {};
            this.removalTimers = {};
            Object.keys(hotkeyHandles).forEach((id) => unlistHotkey(id));

            if (immediate) {
                this.items = [];
                return;
            }

            this.items.forEach((toast) => {
                toast.closed = true;
                toast.visible = false;
            });
            this.clearTimer = window.setTimeout(() => {
                this.items = [];
                this.clearTimer = null;
            }, LEAVE_MS);
        },
    };

    window.Alpine.store('toast', store);

    const isTyping = (target) => {
        if (!(target instanceof HTMLElement)) return false;
        if (target.isContentEditable) return true;
        if (target.matches('textarea, select')) return true;

        return target.matches('input') && !['checkbox', 'radio', 'button', 'submit', 'reset', 'range', 'color', 'file'].includes((target.getAttribute('type') || 'text').toLowerCase());
    };

    // One listener for every toast: the key of an action (such as Z for
    // Undo) runs the newest visible toast that offers it.
    window.addEventListener('keydown', (event) => {
        if (event.defaultPrevented || event.isComposing || event.ctrlKey || event.metaKey || event.altKey || event.repeat) return;
        const key = String(event.key || '').toLowerCase();
        if (key.length !== 1 || isTyping(event.target)) return;
        // The hotkeys single-key shortcuts switch (WCAG 2.1.4) turns this key off too.
        if (hotkeysRegistry()?.characterKeys === false) return;
        const modal = document.querySelector('[aria-modal="true"]');
        if (modal && modal.getClientRects().length > 0 && !(event.target instanceof Node && modal.contains(event.target))) return;
        // The reactive store proxy, so the toast leaves the screen at once.
        const toasts = window.Alpine.store('toast');
        for (let i = toasts.items.length - 1; i >= 0; i--) {
            const toast = toasts.items[i];
            if (!toast.closed && toast.action?.key === key) {
                event.preventDefault();
                toasts.act(toast.id);

                return;
            }
        }
    });

    const pending = window.brokToastQueue ?? [];
    window.brokToastQueue = [];
    pending.forEach((options) => store.push(options));
    // Toasts queued before the hotkeys registry started are listed once every
    // item has started.
    document.addEventListener('alpine:initialized', () => store.items.forEach((toast) => toast.visible && listHotkey(toast)), { once: true });

    window.addEventListener('pagehide', () => {
        // The seen keys stay: a page restored from the back-forward cache
        // runs no script again, and Livewire may replay its events then.
        store.clear({ immediate: true });
    }, { once: true });
});

window.toast = (options) => {
    const store = window.Alpine?.store?.('toast');
    if (store) return store.push(options);

    window.brokToastQueue ??= [];
    window.brokToastQueue.push(options);

    return null;
};

Ownership & lifecycle

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