<x-toast> hosts the snackbar queue for the Toasts concern and window.materialToast(), one at a time, paused on hover and focus, with an optional action; it listens from the moment its script loads, so a toast dispatched before Alpine starts is shown rather than lost. <x-badge> is M3's dot and count, plus a tonal or outlined status label; <x-rich-tooltip> is transient or persistent; <x-alert>, <x-stat> and <x-empty-state> are built from M3's parts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
17 KiB
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 forsecondaryandtertiary;error,on-error,error-container,on-error-container;success,warningandinfowith theiron-,-containerandon-…-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; pluswhiteandblack. - 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}andtype-emphasized-…. Never assembletext-*,leading-*andtracking-*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) andease-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 elementrelativeandisolate),focus-ring(keyboard focus indicator),link(a link in running text). dark:follows the page's theme (data-theme), not the operating system.x-figureon an element holding one number counts it up on first appearance and on change.
Theme
config/livewire-material.php → theme.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).
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-badge>
<x-badge />— M3's small badge, a dot.<x-badge value="4" max="99" />— M3's large badge, a count. Botherrorby default.floatingpins it to the top-end corner of arelativeparent:<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>. A dot or count isaria-hiddenunless it has alabel; 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(aliastone):errordefault,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.
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 and unknown symbol names, with path:line for each.
Conventions
- Components are anonymous Blade components:
<x-name>without a prefix, or<x-{prefix}name>whenconfig('livewire-material.prefix')is set. - Write class names out whole. Tailwind cannot compile
'text-'.$toneortype-{{ $size }}, and the design guard cannot read them. - The showcase at
/material(local only,MATERIAL_SHOWCASE=trueto 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:propbindings, 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()isfalse, notnull, when there is nowire:model, andfilled(false)is true. Normalise with?: null.- End every statement in a multi-line Alpine attribute with
;: an inline@if … @endifinside it swallows the newline after it.