Files
livewire-material/resources/boost/skills/livewire-material-development/SKILL.md
T
Andreas Reinhold / reiniandClaude Opus 5 8f174520eb
tests / feature (8.5) (push) Successful in 1m8s
tests / browser (safari, webkit) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / lint (push) Successful in 1m5s
tests / feature (8.4) (push) Successful in 1m11s
tests / browser (chrome, chromium) (push) Failing after 6m9s
Add M3 error pages and the Markdown mail theme
Error pages for 401 to 503 in an error-view root appended to view.paths,
with an inline fallback when the Vite build is missing, and a mail theme
rendered from the application's scheme JSON, with opt-in header and
message components. Completes Phase 9.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 08:55:58 +02:00

51 KiB
Raw Blame History

name, description
name description
livewire-material-development Build Laravel and Livewire views with Livewire Material's Material 3 Expressive Blade components — props, slots, colour roles, type, shape, motion, theming, toasts, the design guard, and the Livewire traps each component handles.

Livewire Material Development

When to use this skill

Use this skill when writing or changing any Blade view, Livewire component view or layout in an application that requires nonameweb/livewire-material, and when styling, theming or testing such views.

Setup

Composer packages must be installed before the Vite build (in Dockerfiles and CI alike), because the application's build imports from vendor/:

/* resources/css/app.css */
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
// resources/js/app.js
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'

Every layout puts the theme script in <head>, before @vite:

<head>
    <x-theme-script />
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Colour scheme

The scheme is generated, never hand-edited. Regenerate it with the seed and variant recorded at the top of resources/css/material-scheme.css:

php artisan material:scheme "#4f46e5" --variant=tonal-spot

Variants: tonal-spot (M3's default), vibrant, expressive, neutral, fidelity, content, monochrome, rainbow, fruit-salad. --success, --warning and --info set the source of the state colours; --contrast goes from -1 to 1. The command also writes material-scheme.json beside the stylesheet.

Tokens

Tailwind's default palette is cleared: every colour class names an M3 role. text-red-600, bg-base-200 or text-gray-500 compile to nothing.

  • Colour roles (bg-*, text-*, border-*, …): primary, on-primary, primary-container, on-primary-container, inverse-primary, primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant; the same for secondary and tertiary; error, on-error, error-container, on-error-container; success, warning and info with their on-, -container and on-…-container; inverse-error|success|warning|info; surface, surface-dim, surface-bright, surface-container-lowest|low||high|highest, on-surface, on-surface-variant, inverse-surface, inverse-on-surface, outline, outline-variant, scrim, shadow; plus white and black.
  • Ink and lines by meaning: text-body (body copy), text-meta (metadata), text-quiet (decoration only), border-structure, border-chrome, border-divider / divide-divider.
  • Type: type-{display|headline|title|body|label}-{lg|md|sm} and type-emphasized-…. Never assemble text-*, leading-* and tracking-* by hand. The font is Google Sans Flex (font-sans).
  • Shape: rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}.
  • Elevation: shadow-elevation-{1…5} — for what floats over content, not for panels (a panel separates by its container tone).
  • Motion: ease-spatial-{fast|default|slow} (position, size, shape; springs that overshoot) and ease-effects-{fast|default|slow} (colour, opacity). Always pair an easing with its duration: duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast. Reduced motion zeroes the durations.
  • States: state-layer (M3's hover/focus/press overlay; makes the element relative and isolate), focus-ring (keyboard focus indicator), link (a link in running text).
  • dark: follows the page's theme (data-theme), not the operating system.
  • x-figure on an element holding one number counts it up on first appearance and on change.

Theme

config/livewire-material.phptheme.default (light, dark or system), theme.storage_key, theme.legacy_keys. In Alpine, $store.theme holds choice (what the visitor picked), resolved (light or dark, what shows), set('light'|'dark'|'system') and toggle(); x-model="$store.theme.value" binds a control.

Toasts

use NoNameWeb\LivewireMaterial\Concerns\Toasts;

class Settings extends Component
{
    use Toasts;

    public function save(): void
    {
        // …
        $this->success('Settings saved');                         // also warning(), error(), info()
        $this->info('Link copied', timeout: 6000);
        $this->success('Share created', redirectTo: route('shares.show', $share));
    }
}

The methods are protected. They dispatch a toast browser event (assertDispatched('toast', type: 'success', title: 'Settings saved') in tests).

Error pages

Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to view.paths after the application's, so a file in resources/views/errors/ always wins.

  • The pages load config('livewire-material.showcase.vite') and <x-theme-script />, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from resources/css/material-scheme.json.
  • abort(403, 'Only the owner can open this share.') and abort(503, '…') show the message as the sentence. Every other string goes through __(); translate them in lang/{locale}.json.
  • To change wording or design, run php artisan vendor:publish --tag=livewire-material-errors, which copies the layout and pages to resources/views/errors. A page extends errors::minimal and sets title, code, headline, message, shape (an <x-shape> name) and optionally actions:
@extends('errors::minimal')

@section('title', __('Payment Required'))
@section('code', '402')
@section('shape', 'cookie-4')
@section('headline', __('Your plan has ended'))
@section('message', __('Choose a plan to keep using the app.'))

@section('actions')
    <x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
@endsection
  • Maintenance mode: php artisan down --render="errors::503".
  • The showcase previews each page at /material/errors/{code}.

Mail

Markdown mail (notifications and markdown: mailables) wears M3 once the application selects the theme:

MAIL_MARKDOWN_THEME=livewire-material::mail.theme

or per mail: (new MailMessage)->theme('livewire-material::mail.theme'), or public $theme = 'livewire-material::mail.theme'; on a mailable.

  • Colours are the light scheme from resources/css/material-scheme.json (livewire-material.scheme), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
  • Write the body as Markdown; the theme styles the bare tags (# headings, prose, lists, tables) with M3's typescale. <x-mail::button :url="…"> is a filled pill in primary; color also takes secondary, tertiary, error, success, warning and info. <x-mail::panel> is a tinted container.
  • There is no dark mail. Never put @media rules, CSS variables or color-mix() in mail CSS: the inliner strips media queries and mail clients resolve no variables.
  • The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set MATERIAL_MAIL_COMPONENTS=true, or php artisan vendor:publish --tag=livewire-material-mail to copy them into resources/views/vendor/mail. For a logo set livewire-material.mail.logo to ['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]: an absolute URL, with the image at twice those dimensions.
  • With those components, a mail can replace the footer:
<x-mail::message>
Your export is ready.

<x-slot:footer>
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
</x-slot:footer>
</x-mail::message>
  • The showcase renders a sample mail at /material/mail.

Components

<x-icon>

A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.

Prop Default
name required calendar_month, cloud_upload, content_copy
filled false the filled symbol — M3 uses it for active or selected
label null names the icon for screen readers when it carries the meaning alone; otherwise it is aria-hidden

24px (size-6) unless a size-*, w-* or h-* class is passed. Colour follows the text: <x-icon name="lock" class="size-5 text-on-surface-variant" />.

<x-shape>

One of M3 Expressive's 35 shapes, filled in the text colour, aria-hidden, sized by its caller: <x-shape name="cookie-9" class="size-40 text-secondary-container" />. Names: circle, square, slanted, arch, fan, arrow, semi-circle, oval, pill, triangle, diamond, clam-shell, pentagon, gem, very-sunny, sunny, cookie-4, cookie-6, cookie-7, cookie-9, cookie-12, ghostish, clover-4, clover-8, burst, soft-burst, boom, soft-boom, flower, puffy, puffy-diamond, pixel-circle, pixel-triangle, bun, heart.

<x-theme-script>

The theme decided before the first paint. Exactly once per layout, in <head>, before @vite. No props; configured in config/livewire-material.php.

<x-button>

Label button, icon button, toggle and responsive FAB in one component.

Prop Default
label / slot the words; without them and with an icon it is an icon button
variant text filled, tonal, outlined, elevated, text
color (alias tone) primary primary, secondary, tertiary, error, success, warning, info
primary, danger, caution shorthands: filled primary, filled error, filled warning
size sm xs 32px, sm 40px, md 56px, lg 96px, xl 136px
shape round or square; both square off further while pressed
icon, icon-right Material Symbol names
width default icon buttons only: narrow, default, wide
selected null true/false makes it a toggle (aria-pressed, selected colours and shape)
link, external, no-wire-navigate renders <a>, with wire:navigate unless external
spinner true shows the loading indicator while its wire:click runs; a string names the action
tooltip, tooltip-left, tooltip-right, tooltip-bottom plain tooltip; also the icon button's accessible name
disabled, type, responsive, fab responsive hides the label below lg; fab is an extended FAB below sm, a filled button above
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />

<x-tooltip>

M3's plain tooltip, standalone around any trigger: <x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>. side: top (default), bottom, left, right. Shows on hover (fine pointers) and keyboard focus; aria-hidden, so the trigger still needs its own accessible name. Buttons and FABs take a tooltip prop instead.

<x-menu>, <x-menu-item>, <x-menu-group>, <x-menu-separator>

<x-menu label="Share actions" position="bottom-end">
    <x-slot:trigger>
        <x-button icon="more_vert" tooltip="More" />
    </x-slot:trigger>

    <x-menu-group label="Sort by">
        <x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
    </x-menu-group>
    <x-menu-separator />
    <x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
    <x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
</x-menu>

<x-menu>: trigger slot (its first button or link becomes the menu button), label, position (bottom-start default, bottom-end, top-start, top-end), vibrant. <x-menu-item>: label, icon, icon-right, description, shortcut, link, external, selected (makes it a menuitemcheckbox), disabled, keep-open. Choosing an item closes the menu unless keep-open. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.

<x-button-group>

A row of <x-button>s: <x-button-group label="View" size="md">…</x-button-group>. connected sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the size of the buttons inside.

<x-group>

A choice between a few options as a connected button group of native radios (checkboxes with multiple):

<x-group label="Expires after" wire:model.live="expiry" :options="[
    ['id' => '1h', 'name' => '1 hour'],
    ['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
    ['id' => '7d', 'name' => '7 days', 'disabled' => true],
]" hint="Recipients lose access after that" />

Props: label, hint, name (required with x-model), options, option-value (id), option-label (name), option-icon (icon), size, variant (tonal, filled, outlined), multiple, inline (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.

<x-split-button>

<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
    <x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>

Attributes go to the leading button; the slot is the menu. variant (filled default, tonal, outlined, elevated), color, size, disabled, spinner, menu-label, position.

<x-fab>

<x-fab icon="add" tooltip="New share" />size sm 56px (default), md 80px, lg 96px; with label it is an extended FAB. color primary/secondary/tertiary, drawn in the container, or variant="filled". It does not position itself; wrap it (<div class="fixed end-4 bottom-4">). link, external, disabled, type.

<x-fab-menu>, <x-fab-menu-item>

<div class="fixed end-4 bottom-4">
    <x-fab-menu label="New">
        <x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
        <x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
    </x-fab-menu>
</div>

Two to six items open above the FAB, which turns into a close button. <x-fab-menu>: icon (add), label, color, position (top-end default). Give items the same color. Keyboard as <x-menu>.

<x-loading>

M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and primary unless sized or coloured by class; contained sets it on a primary-container circle. A progressbar named by label ("Loading"); :label="false" makes it decorative. It rests under reduced motion.

<x-loading />
<x-loading contained class="size-8" label="Uploading" />
<div wire:loading.flex wire:target="upload"><x-loading /></div>

<x-button spinner> shows a decorative one in place of its icon.

<x-toast>

The snackbar host. Once per layout, near the end of <body>: <x-toast /> (position="bottom-start" to leave the centre free). It is @persisted across wire:navigate and shows, one at a time, every toast from the Toasts concern (see Toasts above) or from JavaScript:

materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })

type (success, error, warning, info) adds the state icon; timeout: 0 keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.

<x-progress>

M3 Expressive's progress indicator: linear (as wide as its container) or circular (40px, 48px wavy, unless a size-* class is passed), flat or wavy, determinate with a value or indeterminate without one.

Prop Default
value null 0 to max, clamped; null is indeterminate
max 100
bind null an Alpine expression it follows in the browser; null/undefined is indeterminate
circular false circular instead of linear
wavy false Expressive's wave (flat below 10% and from 95%)
thick false 8px track and indicator instead of 4px
color primary primary, secondary, tertiary, error, success, warning, info; the track is the colour's container (secondary-container for primary)
label "Progress" names the progressbar; :label="false" makes it decorative
<x-progress :value="$share->uploaded" :max="$share->size" label="Uploading" />
<x-progress circular wavy label="Preparing the download" />

<div x-data="{ progress: null }" x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-finish="progress = null">
    <input type="file" wire:model="file">
    <div x-show="progress !== null"><x-progress bind="progress" wavy label="Uploading" /></div>
</div>

A value the server changes animates after a morph (the SVG is wire:ignore; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A w-* class narrows a linear one; never pass a display or position class.

<x-badge>

  • <x-badge /> — M3's small badge, a dot. <x-badge value="4" max="99" /> — M3's large badge, a count. Both error by default. floating pins it to the top-end corner of a relative parent: <span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>. A dot or count is aria-hidden unless it has a label; name the control instead ("Messages, 4 unread").
  • <x-badge value="Expired" tonal />, <x-badge value="Active" color="success" tonal />, <x-badge value="Pro" outline /> — a status label (not an M3 badge) in the colour's container or a neutral edge. color (alias tone): error default, primary, secondary, tertiary, success, warning, info.

<x-alert>

A notice in the page, in the state's container colour with its icon:

<x-alert title="Storage almost full" description="3.8 GB of 4 GB used." color="warning" />
<x-alert color="error" dismissible>
    The upload failed.
    <x-slot:actions><x-button label="Try again" wire:click="retry" /></x-slot:actions>
</x-alert>

color (alias tone): info (default), success, warning, error, primary, secondary, tertiary, neutral. icon overrides the state icon; :icon="false" removes it. Errors and warnings are role="alert", the rest role="status".

<x-rich-tooltip>

A few lines of context around a trigger, with an optional title and actions slot:

<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
    <x-button icon="help" aria-label="About expiry" />
</x-rich-tooltip>

Shows on hover and keyboard focus; persistent opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). side: bottom (default), top, left, right.

<x-stat>

<x-stat title="Shares" value="1,204" icon="link" description="12 this week" /> — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's <x-progress>). Do not pass a bg-* class; wrap it.

<x-empty-state>

"Nothing here yet": icon on an Expressive shape (cookie-9 by default), title, description or slot, and an actions slot. Use it for an empty collection, not for a filter that matched nothing.

<x-card>

variant: filled (default, surface-container-highest), elevated, outlined; medium corner. Props title, subtitle, separator; slots figure (full-bleed media), menu (top-end), actions (end-aligned). Do not pass bg-*; use variant.

A card or list item that opens something is a row: data-list-row on it and data-list-open on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in <a> or use a stretched link.

<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
    <a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
    <x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
</x-card>

<x-list>, <x-list-item>

<x-list>: label, dividers, segmented (M3 Expressive: separate tiles 2px apart). <x-list-item>: title (or slot), overline, description, leading icon / avatar (image URL or initials) / image / leading slot, trailing trailing text / icon-right / end slot, link (the whole item becomes a row that opens it), selected, disabled. One-, two- and three-line heights follow from the content.

<x-list segmented label="Files">
    @foreach ($files as $file)
        <x-list-item :title="$file->name" :description="$file->size" icon="description" wire:key="file-{{ $file->id }}">
            <x-slot:end><x-button icon="download" tooltip="Download" wire:click="download({{ $file->id }})" /></x-slot:end>
        </x-list-item>
    @endforeach
</x-list>

<x-divider>

<x-divider /> — outline-variant line; vertical, inset (16px start), middle, decorative (hidden from assistive tech).

<x-collapse>

A disclosure on native <details>: <x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse> (variant plain or filled; heading slot for rich titles). Keeps its state through a morph.

<x-modal>

An M3 dialog on native <dialog>. Bind with wire:model to a flag or an id; closing (Escape, scrim, close()) writes back false or null. Without wire:model it uses open from the surrounding Alpine scope.

<x-modal wire:model="deletingId" title="Delete this share?" subtitle="Recipients lose access at once." icon="delete">
    <x-slot:actions>
        <x-button label="Cancel" x-on:click="close()" />
        <x-button label="Delete" danger wire:click="delete" />
    </x-slot:actions>
</x-modal>

Props: title, subtitle, icon (centred hero icon), separator, persistent (no Escape or scrim), fullscreen (whole screen below sm, for forms), box-class. Never remove its wire:ignore.self behaviour by re-rendering it conditionally with @if; toggle the bound property instead.

<x-drawer>

An M3 side sheet, bound like <x-modal>; close() in scope. Props: title, subtitle, separator, side (end default, start), width (25rem), with-close-button, close-on-escape (default true), without-backdrop-close, actions slot. pane (with pane-width) turns it into a list-detail pane from xl: render it after the list inside <div class="xl:flex xl:items-start xl:gap-6">. Its body is a size container — lay out inside with @md: etc., not sm:.

<x-bottom-sheet>

An M3 bottom sheet, bound like <x-modal>: modal by default (scrim, inert page, drag the handle down or press Escape to close), standard for one that is part of the page. Props: title, height (90dvh), actions slot.

<x-carousel label="Recent uploads" item-width="220">
    @foreach ($photos as $photo)
        <x-carousel-item :label="$photo->title" wire:key="photo-{{ $photo->id }}">
            <img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
        </x-carousel-item>
    @endforeach
</x-carousel>

A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). <x-carousel>: layout (multi-browse default, hero, uncontained, full-screen), item-width (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), height (205px), padding (px at the ends, 0), centered (hero), label (the region's name, "Carousel" by default), controls (previous/next buttons: default fine pointers only, true always, false never). <x-carousel-item>: slot is an <img> (fills and crops) or an element sized size-full; label overlays a line of text. A focusable region of slide groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a wire:key in a loop.

<x-chip>

One component for M3's four chips, picked by type:

type What it is Element
assist (default) an action <button>, or <a> with link
filter a toggle a native checkbox under the chip with wire:model, x-model or name; otherwise a <button aria-pressed> whose selected you own
input something a person entered its own button only with wire:click, x-on:click, link or selected; a remove button with removable
suggestion a suggested reply or query <button>

Props: label / slot, icon, icon-right, elevated (not on input chips), disabled, link, external, no-wire-navigate, selected (filter and input), name / value (a filter checkbox; an input chip's hidden input, value defaulting to the label), avatar (input: image URL or initials), removable, remove (input: an Alpine expression), tooltip. On a filter checkbox and an input chip, class, style and wire:key stay on the chip and every other attribute goes to the control inside.

<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />

<x-chip-set label="File types" hint="Show only these" error-field="kinds">
    @foreach ($kindOptions as $kind => $name)
        <x-chip type="filter" :label="$name" :value="$kind" wire:model.live="kinds" wire:key="kind-{{ $kind }}" />
    @endforeach
</x-chip-set>

<x-chip type="filter" label="Starred" icon="star" :selected="$starredOnly" wire:click="$toggle('starredOnly')" />

@foreach ($recipients as $recipient)
    <x-chip type="input" :label="$recipient->email" :avatar="$recipient->initials" removable wire:remove="removeRecipient({{ $recipient->id }})" wire:key="recipient-{{ $recipient->id }}" />
@endforeach
  • A multi-select set binds wire:model on every chip, each with its own value, to an array property; a boolean property needs no value. The chips render checked as the property already says.
  • A removable input chip removes through wire:remove (it becomes the remove button's wire:click), remove (Alpine), or, with neither, takes itself off the page. Backspace or Delete on a focused chip removes it and moves focus to the previous or next chip; the remove button is named "Remove ". Give each one a wire:key.

<x-chip-set>

A row of chips 8px apart that wraps, as role="group": label (shown, and names the group; otherwise pass aria-label), hint, error-field (a validation message for that property or its items replaces the hint), scroll (one line that scrolls sideways, fading the edge it can still scroll towards).

<x-form>

A one-column grid of fields with an actions slot at the foot (the slot takes its own class); separator draws a divider above the actions.

<x-form wire:submit="save">
    <x-input label="Share name" wire:model="name" required />
    <x-select label="Expires after" wire:model="hours" :options="$expiryOptions" />
    <x-slot:actions>
        <x-button label="Cancel" wire:click="cancel" />
        <x-button label="Create share" variant="filled" type="submit" spinner="save" />
    </x-slot:actions>
</x-form>

<x-field>, <x-input>, <x-password>, <x-textarea>, <x-select>, <x-file>

M3 text fields. variant: outlined or filled; without it, config('livewire-material.fields.variant') (outlined). All take label, hint, variant, and read their errors from the bag under the wire:model name (the error replaces the hint, sets aria-invalid). class lands on the field's outer element (margins, widths); every other attribute (wire:model, type, required, readonly, autocomplete) reaches the control. Never pass placeholder expecting it to show while a label rests in the field: it shows once the field has focus.

  • <x-input>: icon, icon-right, prefix, suffix, clearable, copyable (copies the value, confirms with a snackbar), size (sm 40px, xs 32px — for unlabelled toolbar controls; give them aria-label), mono.
  • <x-password>: a reveal button; icon, size.
  • <x-textarea>: grows from rows (3) to max-rows, then scrolls; :autogrow="false" for a fixed, hand-resizable one.
  • <x-select>: native <select> (M3 menu where the browser supports customizable selects). options as ['id' => …, 'name' => …, 'disabled' => bool], option-value, option-label, placeholder + placeholder-value, or <option>s in the slot; icon, size.
  • <x-file>: native file input; errors from photos and photos.*. Show previews of what was chosen yourself.
  • <x-field id="…" label="…" :messages="$messages"> wraps a custom control given class="field-control"; only for controls the package does not have.

<x-checkbox>, <x-radio>, <x-toggle>

M3 selection controls on native inputs; the whole row is the label.

  • <x-checkbox label hint right indeterminate />indeterminate for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
  • <x-radio label wire:model :options inline /> — options ['id', 'name', 'hint', 'disabled'] (option-value, option-label, option-hint); value checks an option without wire:model; name names an unbound group.
  • <x-toggle label hint right icons /> — M3 switch (role="switch"); icons puts a check and a cross on the handle, icons="selected" only the check. Without label, pass aria-label.
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
<x-toggle label="Notify me on download" wire:model.live="notify" right />

<x-slider>

M3 Expressive's slider on native <input type="range">s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.

<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
<x-slider label="Price" wire:model="price" range :max="500" :step="10" ticks />
<x-slider label="Balance" x-model="balance" name="balance" :min="-50" :max="50" centered />
<x-slider label="Brightness" name="brightness" value="60" size="lg" icon="light_mode" />
Prop Default
label, hint the label above names the input (with range, the group); a validation error for the bound property or name replaces the hint
value, min, max, step null, 0, 100, 1 as on a range input; step="any" is continuous. With wire:model the property's value is drawn
name the wire:model property with range it posts name[] twice, from first
range false two handles that never cross; binds an array [from, to] (wire:model="price" binds price.0 and price.1, x-model="price" binds price[0] and price[1])
centered false fills from the middle of the track, for values that go below zero
size xs track xs 16px, sm 24px, md 40px, lg 56px, xl 96px
icon null a Material Symbol inside the track, md and up, standard sliders only
ticks false a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks
value-label drag drag (while pressed, dragged or keyboard-focused), always, never
color primary primary, secondary, tertiary, error, success, warning, info
disabled false

Other attributes go to the input(s). A wire:model.live slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is wire:ignore); a value the server sets moves the handle after the morph. The binding gets .number, so values arrive as numbers. Its width is the container's unless a w-* class is passed.

<x-datepicker>

M3 date pickers on a text field. wire:model stores Y-m-d strings (x-model without Livewire).

<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
Prop Default
mode docked docked: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below sm; modal: the field opens a calendar dialog; input: the dialog opens on a text field. Both dialogs switch between calendar and typing
range false binds one array property, ['start' => 'Y-m-d', 'end' => 'Y-m-d'] (either may be null); errors for trip, trip.start and trip.end show on the field
min, max null Y-m-d or a date; days outside are disabled and the keyboard stays inside
label, hint, icon, variant, size the field's
value null the initial value without wire:model
name adds hidden inputs with Y-m-d for a plain form post (name[start], name[end] for a range)

Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow app()->getLocale(). Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. min and max are read when the picker starts: when they change on the server, give the component a wire:key that changes with them. required, disabled and readonly reach the text field.

<x-timepicker>

M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 1223 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.

<x-timepicker label="Starts at" wire:model="startsAt" />
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
Prop Default
wire:model / x-model the time as H:i, null until chosen; H:i:s (a time column) is read and written back as H:i; nothing is written until OK
format the locale's 12 or 24; otherwise the hour cycle of locale as Intl.DateTimeFormat reports it
locale app locale the hour cycle, and how the field writes the time
step 1 minutes between choices (a tap picks fives, or steps when five is not a multiple of the step)
min, max H:i, inclusive; min later than max spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well
clearable false a button that empties the field
name posts the value from a hidden input
label, hint, icon, variant, size the field's; required, disabled and placeholder reach its input

Errors under the wire:model name replace the hint. The dialog is wire:ignore: a Livewire render leaves an open picker open with its draft. Never name a Livewire property $slot: it renders empty in the component's view.

<x-choices>

Choosing from a list, with typed values (an array of integers stays integers). options (id, name, disabled; option-value, option-label), label, hint, single. Errors for the property and its items replace the hint.

  • Default: filter chips, every option on screen — single for choice chips.
  • searchable: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); icon, variant, placeholder. Its list is a popover, so it is never clipped by a card.
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
<x-choices label="Time zone" wire:model="timezone" :options="$timezones" searchable icon="public" />

Bind with wire:model (entangled) or, without Livewire, x-model. The options are baked into its Alpine state: when they change on the server, give it a wire:key that changes with them.

M3 search bar that opens into a search view: docked under the bar from sm, full screen with a back arrow below (docked keeps it docked). Bind the input like any other and render the results in the slot; empty is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: placeholder ("Search"), label, icon; trailing slot (avatar, icon buttons).

<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
    @foreach ($this->results as $share)
        <x-list-item :title="$share->name" :description="$share->size" link="{{ route('shares.show', $share) }}" wire:key="result-{{ $share->id }}" />
    @endforeach
    <x-slot:empty>No shares match.</x-slot:empty>
</x-search>

The docked view overlaps what is under it; never place a search inside an element with overflow-hidden (a card), which clips it.

<x-app-shell>

The adaptive app shell, a whole layout's body: a navigation bar below sm, a collapsed rail that opens as a modal to lg, an expanded rail the visitor can collapse from lg, the page as <main id="content" wire:transition.navigate> behind a skip link, and the snackbar host (do not add another <x-toast />). It needs <x-theme-script /> in <head>.

<x-app-shell :destinations="[
    ['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
    ['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
    ['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
]">
    <x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
    <x-slot:rail-header>
        <span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
        <span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
    </x-slot:rail-header>
    <x-slot:rail-footer>
        <x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
    </x-slot:rail-footer>
    <x-slot:top>
        {{-- the page's app bar; its menu button opens the modal rail on a phone --}}
        <span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
    </x-slot:top>

    {{ $slot }}
</x-app-shell>
  • destinations: title, icon, url; optional active (default: the URL is the current one), badge (true for a dot, or a count), section (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), bar (default true; false keeps it out of the bottom bar — M3 wants three to five there), navigate (false for a full page load instead of wire:navigate).
  • Slots, each rendered once: brand (beside the rail's menu button, expanded only), rail-header (a FAB), rail-footer (pinned to the foot of the rail), actions (a row of icon buttons at the very foot, stacked when collapsed), top (the app bar, above the page at every width), and the page. label names the landmarks ("Main"); rail-width is the expanded width (16rem).
  • The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below sm nothing opens it but $store.rail.show(), so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from sm).
  • Below sm the shell sets --material-bottom-bar, so the snackbar and a fab button clear the bar; pad anything else you pin to the bottom with it.
  • The content region is max-lg:overflow-x-clip. Never make a page wrapper overflow-x-hidden: it turns the region into a scroll container and breaks every sticky inside.

<x-navigation-bar>, <x-navigation-bar-item>

M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (<x-app-shell> does):

<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
    <x-navigation-bar>
        <x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
        <x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
    </x-navigation-bar>
</div>

64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. <x-navigation-bar>: label ("Main"). <x-navigation-bar-item>: label / slot, icon, link (with wire:navigate unless external or no-wire-navigate; without a link it is a button), active (aria-current="page", filled icon, secondary-container indicator), badge (true for a dot, a number for a count, 999+ at most), badge-label (what a screen reader hears instead of ", 3").

<x-navigation-rail>, <x-navigation-rail-item>, <x-navigation-rail-section>

M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).

<div class="flex min-h-dvh">
    <x-navigation-rail mode="collapsible">
        <x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
        <x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>

        <x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
        <x-navigation-rail-section label="Admin">
            <x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
        </x-navigation-rail-section>

        <x-slot:footer>
            <x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
        </x-slot:footer>
    </x-navigation-rail>

    <main class="min-w-0 flex-1">…</main>
</div>
  • mode: collapsed, expanded, collapsible (default: expanded until its menu button collapses it; the choice is $store.rail, remembered and applied before the first paint), modal (collapsed in the layout; the menu button or $store.rail.show() opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), adaptive (<x-app-shell>'s: hidden and opened as a modal below sm, collapsed and opened as a modal to lg, collapsible from lg).
  • Props: label ("Main"), width (expanded width, 16rem, held between 220 and 360px), menu (the menu button; on by default for collapsible, modal, adaptive). Slots: brand (beside the menu button, expanded only), header (a FAB), the destinations (the only part that scrolls), footer. In a flex row the rail sticks to the top of the viewport.
  • Anything inside a rail takes both shapes with the rail-collapsed: variant, true while that rail is drawn collapsed for whatever reason: <span class="rail-collapsed:hidden">…expanded only…</span>, <span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
  • <x-navigation-rail-item>: the same props as <x-navigation-bar-item>. <x-navigation-rail-section label="…">: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
  • $store.rail: collapsed, toggle(), collapse(), expand() (the remembered choice), open, show(), hide() (the modal rail; closed on every wire:navigate). config/livewire-material.phprail.default (expanded or collapsed) and rail.storage_key (material-rail).

<x-app-bar>

M3 Expressive top app bar, sticky by default (:sticky="false" to scroll away), turning surface-container once content scrolls under it. variant: small (default), center, medium and large (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), search (put an <x-search> in the slot). Props: title, subtitle, heading (h1 default). Slots: navigation (leading icon button), actions (trailing icon buttons, avatar).

<x-app-bar variant="medium" title="Recipients" subtitle="3 people">
    <x-slot:navigation><x-button icon="arrow_back" tooltip="Back" :link="route('shares.index')" /></x-slot:navigation>
    <x-slot:actions><x-button icon="person_add" tooltip="Add recipient" wire:click="add" /></x-slot:actions>
</x-app-bar>

A collapsing bar needs the window to scroll: no ancestor with overflow-hidden/overflow-auto (overflow-x-clip is fine).

<x-toolbar>

M3 Expressive toolbar, role="toolbar" (arrow keys move between controls). variant: floating (default pill at elevation 3; vibrant, vertical) or docked (full-width surface-container bar). place: bottom or end to fix it over the page; fab slot sets a FAB beside a floating toolbar; label names it.

<x-toolbar label="Selection" place="bottom" vibrant>
    <x-button icon="download" tooltip="Download" wire:click="download" />
    <x-button icon="delete" tooltip="Delete" wire:click="delete" />
    <x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
</x-toolbar>

<x-tabs>, <x-tab>

M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). tabs: ['name', 'label', 'icon', 'badge', 'disabled']; panels are <x-tab name> in the slot. Bind with wire:model (entangled), or selected / x-model without Livewire. variant primary (default) or secondary; stacked (icon over label), scrollable. Give two identical tab sets on one page distinct ids.

<x-tabs wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People', 'badge' => $pending]]">
    <x-tab name="files">…</x-tab>
    <x-tab name="people">…</x-tab>
</x-tabs>

<x-section-nav>

Navigation between the sections of one area (settings, admin): secondary tabs as links from sm (wrapping onto a grid rather than scrolling), a menu picker below. items: ['title', 'url', 'icon', 'active', 'badge'] — current when active or its url is the request's. label, no-wire-navigate.

<x-account-menu>

An avatar that opens a menu: name, email, avatar (image URL or initials; default the name's initials), items in the slot, a theme item (:theme="false" to drop it), and a footer slot for signing out. label, position.

<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
    <x-menu-item label="Settings" icon="settings" :link="route('settings')" />
    <x-slot:footer>
        <form method="POST" action="{{ route('logout') }}">@csrf<x-menu-item label="Sign out" icon="logout" type="submit" /></form>
    </x-slot:footer>
</x-account-menu>

<x-theme-toggle>

Switches $store.theme: mode="toggle" (default, light/dark icon button), cycle (light → dark → system), picker (segmented buttons for settings pages). Every toggle on a page shares the store.

<x-table>, <x-sort-header>

A data table: write plain <thead>, <tr>, <th>, <td> inside <x-table> (size="xs" for a dense one); cell utilities (text-end, whitespace-nowrap) always win. Scrolling is yours: wrap it in <div class="overflow-x-auto">. A row that opens something is data-list-row with one data-list-open control; a selected row is aria-selected="true".

<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header> sorts through the Livewire property sortBy (['column' => …, 'direction' => 'asc'|'desc']; model names another), with aria-sort.

<div class="overflow-x-auto">
    <x-table>
        <thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
        <tbody>
            @foreach ($shares as $share)
                <tr data-list-row wire:key="share-{{ $share->id }}">
                    <td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td>
                    <td class="text-end tabular-nums">{{ $share->size }}</td>
                </tr>
            @endforeach
        </tbody>
    </x-table>
</div>
{{ $shares->links() }}

Pagination: $paginator->links() (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with config('livewire-material.pagination') = false; published vendor/pagination or vendor/livewire views still win.

Testing the design

use NoNameWeb\LivewireMaterial\Testing\DesignGuard;

it('uses only what compiles', function () {
    expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
        ->forbidColours(['tertiary'])          // roles this application's rules leave out
        ->violations())->toBe([]);
});

It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with path:line for each.

Conventions

  • Components are anonymous Blade components: <x-name> without a prefix, or <x-{prefix}name> when config('livewire-material.prefix') is set.
  • Write class names out whole. Tailwind cannot compile 'text-'.$tone or type-{{ $size }}, and the design guard cannot read them.
  • The showcase at /material (local only, MATERIAL_SHOWCASE=true to force it) renders every token and component.

Livewire traps

  • Blade directives do not compile inside a component tag's attributes: <x-foo x-show="ok(@js($value))"> reaches the browser as literal text. On a component tag use {{ }} and :prop bindings, or put the Alpine on a plain element inside the slot.
  • Never pass hidden, a display utility or a position (absolute, relative) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (max-sm:hidden) is safe.
  • $attributes->wire('model')->value() is false, not null, when there is no wire:model, and filled(false) is true. Normalise with ?: null.
  • End every statement in a multi-line Alpine attribute with ;: an inline @if … @endif inside it swallows the newline after it.