Files
livewire-material/resources/boost/skills/livewire-material-development/SKILL.md
T
Andreas Reinhold / reiniandClaude Opus 5 b5c5be1fd7 Add the list-detail canonical layout
Plan step 35: <x-list-detail>, following the reference's visible-panes
table row by row - one pane below expanded, the detail replacing the list
with a back button once something is selected; from 840px the list a
fixed 360px (412px from 1200px) beside the detail, 24px apart. The
selection binds with wire:model or x-model; below expanded focus moves to
the detail and back() returns it to the item (resources/js/layout.js).
Grid columns mirror in RTL, and the back arrow turns with them.

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

103 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. --spec is the colour spec: 2025 (default, M3 Expressive) or 2021 (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). --success, --warning and --info set the source of the state colours; --harmonize pulls those three towards the seed (off by default: a state has to stay recognisable). The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes material-scheme.json beside the stylesheet.

success, warning and info are built exactly as M3 builds error — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (bg-success, text-on-warning-container), never as a hex.

Contrast levels

Every scheme is generated at M3's three levels: standard, medium (3:1) and high (7:1), for light and dark and for every profile. --contrast (and a profile's contrast) moves the standard level only and must stay below 0.5; medium and high are Google's fixed levels and are always written, under [data-contrast='medium'] and [data-contrast='high'].

  • <html data-contrast> is the level on screen, written by <x-theme-script> before the first paint; standard writes no attribute, because the plain blocks are already standard. No stylesheet ever asks prefers-contrast — the head script does, once.
  • theme.contrast.default is system (follow the operating system), standard, medium or high, kept in localStorage under theme.contrast.storage_key.
  • In Alpine: $store.theme.contrast (the choice), $store.theme.resolvedContrast (the level showing) and $store.theme.setContrast('high'). <x-theme-toggle mode="contrast"> is the ready-made row of three.
  • Nothing in a template names a level: a role's value changes underneath it. Never set data-contrast on an element to make a corner of the page higher-contrast — the level is the visitor's, page-wide.

Colour profiles

An installation that switches between several schemes lists them in config/livewire-material.php and runs the command without a seed, which generates every profile into the same stylesheet, keyed by <html data-scheme>:

'profiles' => [
    'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
    'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
],
'profile' => 'indigo', // the default; else the first
php artisan material:scheme
  • Each profile: seed, and optionally label (default: the name as a headline), variant (default tonal-spot), contrast (default 0, below 0.5), harmonize, spec, success, warning, info (for these five, without the key the command's --harmonize, --spec, --success, --warning, --info or their defaults apply). Every profile is generated at all three contrast levels, keyed on [data-scheme='x'][data-contrast='high'] and so on.
  • Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
  • The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
use NoNameWeb\LivewireMaterial\Support\Scheme;

Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
  • <x-theme-script> writes the active profile to <html data-scheme> before the first paint; mails and error pages draw it too. Scheme::profiles() lists the generated profiles (name ⇒ label, light and dark roles) and Scheme::profile() names the active one — validate a stored choice with Rule::in(array_keys(Scheme::profiles())). Both take a contrast level as their last argument (Scheme::load($path, $profile, 'high'), Scheme::profiles(contrast: 'medium')); without one they answer with the standard level, which is what a mail wears.
  • Choose with <x-scheme-picker wire:model="colorProfile" /> (see Components). Never set data-scheme on an element inside the page expecting a different profile there: profiles key on <html>.

Tokens

2.0.0's vocabulary is plain CSS, without Tailwind. resources/css/foundation.css is the one required import, first in the entry (while an application still builds Tailwind: before @import 'tailwindcss', with resources/css/tailwind.css after it in place of material.css). It declares the layer order material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility, so an application's unlayered CSS beats every package rule, and hidden or x-cloak hides any element. It brings the reset, every --md-sys-* token (spacing included: --md-sys-measurement-space25space900, 272px), and md-state-layer, md-focus-ring, md-touch-target, md-link. Text on plain elements takes the fixed text classes of resources/css/text.css and nothing else: md-type-{display|headline|title|body|label}-{lg|md|sm} and md-type-emphasized-…; md-ink (on-surface), md-ink-variant, md-ink-quiet, md-ink-primary, md-ink-error, md-ink-success, md-ink-warning, md-ink-info, md-ink-inverse; md-text-start|center|end, md-truncate, md-line-clamp-2|3, md-nowrap, md-tabular, md-visually-hidden. The Tailwind names below stay for the components not yet rewritten.

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. The rules behind the names below — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the material-3 guideline (always on) and the material-3-design skill (the tables and Google's source pages); activate that skill before designing a screen.

  • 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-… — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble text-*, leading-* and tracking-* by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. 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, which never overshoot). Always pair an easing with its duration: duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. motion.scheme in the config picks expressive (the default, with the bounce) or standard (minimal bounce), which the head script writes to <html data-motion> and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
  • 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).
  • Breakpoints are M3's window size classes, and only those: medium: 600px, expanded: 840px, large: 1200px, extra-large: 1600px, with max-medium: … for "below" (compact is below medium). Tailwind's sm:2xl: are cleared — a sm: compiles to nothing — because 640px means nothing in M3. Scripts ask resources/js/breakpoints.js (from('expanded'), upTo('medium')) so a stylesheet and a script never disagree at the boundary pixel; a component's own width is a container query (@md:), which is a different thing.
  • 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, theme.meta, theme.contrast (default and storage_key, see Contrast levels) and motion.scheme (expressive, the default, or standard — M3's restrained springs, written to <html data-motion>). 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. It also holds contrast, resolvedContrast and setContrast() for the contrast level. With colour profiles it holds scheme (the profile on screen) and previewScheme(name), which shows another profile on this page without storing anything.

theme.meta (default false) keeps the browser's bar in the page's colour, for an installed web app: the head script sets the content of every <meta name="theme-color"> without a media attribute to the resolved theme's surface — of the profile in <html data-scheme>, at the level in <html data-contrast> — before the first paint, adding one to <head> when there is none. It follows every later change of data-theme, data-scheme or data-contrast ($store.theme.set()/toggle(), setContrast(), an OS change while system, previewScheme()), and paints the next page's meta after wire:navigate. A theme-color meta the layout renders itself goes before <x-theme-script /> (after it, the script has already added one, and the page ends up with two), or is left out. A media="(prefers-color-scheme: …)" pair follows the OS instead of the visitor's choice: drop it when turning this on.

Safe areas

Every component that meets the edge of the screen (app bar, navigation bar and rail, docked and placed toolbars, full-screen search, dialog and side sheet, bottom sheet, the skip link) keeps clear of a notch or home indicator through var(--material-safe-top|bottom|left|right, env(safe-area-inset-…)). The layout needs viewport-fit=cover in its viewport meta for the insets to be non-zero. Set a variable to replace the device's inset, on <html> or any ancestor: a browser test fakes a notch with document.documentElement.style.setProperty('--material-safe-top', '47px'), and an app that draws its own status strip adds its height.

--material-snackbar-height is the height of the snackbar on screen, written on <html> by <x-toast> while one shows and removed when it goes. <x-button fab> reads it, so the FAB sits above the snackbar rather than under it, as M3 requires; a placed <x-fab> does the same by wrapping it in <div class="fixed end-4 bottom-[calc(1rem+var(--material-snackbar-height,0px))] large:end-6">.

--material-bottom-extra (default 0px) is the height of anything the application docks on top of the phone's navigation bar in <x-app-shell> (an offline banner): the shell adds it to --material-bottom-bar (64px + the bottom inset), so the snackbar, a fab button and the page's bottom padding clear it too. Set it while the docked element shows, and remove it when it goes; place the docked element itself directly above the bar, at bottom: calc(4rem + var(--material-safe-bottom, env(safe-area-inset-bottom))), on a compact window (below medium) only.

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), 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
optical 24 the cut the glyph is drawn from, 24 or 20; anything else falls back to 24
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" optical="20" />.

M3's optical size axis redraws a symbol so its strokes look equally heavy at every size, so an icon drawn at 20px or smaller (size-5 and below) takes optical="20"; scaling the 24 cut down thins its strokes by about a sixth. The cut is not a size — pass both, and where a component sizes an icon for you, pass the cut alongside the size class.

<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. With theme.meta on it also paints <meta name="theme-color"> (see Theme); a layout's own theme-color meta goes before it.

<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; drawn from M3's 20px cut where the glyph is 20px, and filled on a default icon button
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 expanded; fab is an extended FAB on a compact window (below medium), a filled button from there
<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, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is aria-hidden, so the trigger has to carry the same words itself — as an icon button's aria-label does. Where the tip says something the trigger does not, use <x-rich-tooltip>, which points the trigger at its text. 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, and the menu hangs on that button — a position: fixed trigger such as <x-button fab> carries it along, and a menu with no room flips to the other side, end or both), label, position (bottom-start default, bottom-end, top-start, top-end), vibrant, filter, sheet-at-compact. <x-menu-item>: label, icon, icon-class (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — icon-class="text-sport-run"), icon-right, description, shortcut, link, external, selected (makes it a menuitemcheckbox, ticked at its end unless it has an icon-right), current (for a menu of places: marks the page you are on with aria-current="page" in secondary-container, never a checked choice), badge (true for a dot, or a count, at the end of the row), disabled, keep-open, submenu. Choosing an item closes the menu unless keep-open; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a keep-open item's own wire:click included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a disabled item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.

submenu makes an item a menu of its own — the slot holds the nested <x-menu-item>s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:

<x-menu-item label="Export as" icon="download" submenu>
    <x-menu-item label="ZIP" icon="folder_zip" wire:click="exportZip" />
    <x-menu-item label="PDF" icon="picture_as_pdf" wire:click="exportPdf" />
</x-menu-item>

The item says so with aria-haspopup="menu", aria-expanded and a chevron; Right, Enter or Space open it on its first item, Left or Escape close it and come back, and on a fine pointer resting on the item opens it. Choosing anything inside closes the whole menu. Arrows stay inside the list they are in. M3 calls submenus a large-screen pattern — on a phone give the menu sheet-at-compact, or keep the list flat.

filter puts a text field at the top of the list (M3's menu as a filtering surface) and narrows the items to those whose label holds what has been typed — in the browser, over the items already rendered, so nothing is fetched and every wire:click stays where it was. filter="Find a person" names the field; bare filter calls it "Filter". The field keeps the focus while the arrow keys, Home and End move a highlighted row and Enter chooses it (aria-activedescendant, as <x-choices searchable>); a query that leaves nothing says "Nothing matches". Reach for it once a menu is long enough to hunt through; for a value bound to a property, <x-choices searchable> is the field, not the menu.

sheet-at-compact is M3's adaptive menu ("at compact breakpoints, consider swapping a menu for a bottom sheet"): below medium (600px) the trigger opens the same items in a modal <x-bottom-sheet>, and from medium up it opens the popover. Write the items once — the slot is drawn in both:

<x-menu label="Photo actions" sheet-at-compact>
    <x-slot:trigger>
        <x-button icon="more_vert" tooltip="More" />
    </x-slot:trigger>

    <x-menu-item label="Set as wallpaper" icon="wallpaper" description="Home and lock screen" wire:click="wallpaper" />
    <x-menu-item label="Add to album" icon="photo_album" submenu>
        <x-menu-item label="Holidays" wire:click="addTo('holidays')" />
    </x-menu-item>
    <x-menu-item label="Delete" icon="delete" wire:click="delete" />
</x-menu>

On a compact window the trigger says aria-haspopup="dialog" (and menu from medium), with aria-expanded in both. In the sheet the items keep their menuitem roles and the menu keyboard (arrows, Home, End, a letter; Escape or Tab close it and focus returns to the trigger); choosing an item closes it, as do the scrim and a swipe down; a submenu opens in place under its item rather than beside it; a filter field sits at the top; a keep-open item's render keeps the sheet open. Resizing the window across 600px while it is open closes it. The sheet is teleported to the end of <body>, so it covers the window from inside a sticky app bar or a toolbar, and is surface-container-low even for a vibrant menu. Because the items are rendered twice, do not give them an id or nest a Livewire component in a sheet-at-compact menu.

Clusters: <x-menu-separator /> draws M3's line, <x-menu-group gap> M3 Expressive's grouped layout — no line, the cluster set 8px off its neighbours with its items 2px apart and its ends rounded. Reach for the divider first (M3: "on web, use dividers to separate items", and it is the only one a scrolling menu may use); reach for the gap for one or two clusters in a menu short enough not to scroll, and never vary the gap. <x-menu-group> takes label (optional) and gap; a labelled group without gap is the plain heading it always was.

<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. shape="square" is M3's square group and covers every button in it, so do not write shape on each one: a connected group's ends square to the corner its inner edges take, a standard group's buttons take the square corner scale, and a selected button still rounds — M3 has the toggle morph the other way.

selection is M3's third configuration — single, multi, and either with required ("selection-required"). The group then owns aria-pressed:

<x-button-group connected selection="single" required wire:model.live="view" label="View">
    <x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
    <x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>

Pressing a button writes its value (an array with multi) to wire:model or x-model, deselects the others in single, and with required refuses the press that would leave nothing selected; without a model it reads the buttons' own aria-pressed once and goes on from there. A button with no value is known by its label. The group manages state and shape, not colour — each button draws its selected colours from its own :selected, so bind both from one property as above. Reach for <x-group> first: it is the component for a choice whose options are data (real radios or checkboxes, a plain form post, the browser's keyboard, segments that paint themselves). <x-button-group selection> is for buttons you write yourself — icons, tooltips, mixed content — and never becomes a form control.

<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, hint-class (classes for the hint, as on the fields; a colour there paints it), name (required with x-model), options, option-value (id), option-label (name), option-icon (icon), size, variant (tonal, filled, outlined), shape (round, square), 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. The glyph is filled, as M3 requires of a 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 large:end-6 large:bottom-6"> — M3's 16dp margin, 24dp from large). link, external, type. There is no disabled: M3 says to remove a FAB whose action is unavailable, so hide it instead. data-fab on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).

collapse-on-scroll on an extended FAB with an icon (<x-fab icon="edit" label="Compose" collapse-on-scroll />) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane.

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

<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
    <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, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. <x-fab-menu>: icon (add), label (defaults to "Toggle menu" — the trigger has no other accessible name), color, position (top-end default). Give items the same color. Keyboard, and staying open through a Livewire render, as <x-menu>. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from large.

<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) picks the announcement role and draws no icon (M3 tells you to avoid one in a snackbar); timeout: 0 keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer. A toast with an action never auto-dismisses (M3's rule) unless you write a timeout out.

  • action: label, plus handler (a function) and/or event (a name). Pressing it closes the snackbar, calls handler, then dispatches new CustomEvent(event) on window; give both and both run. Use event where a function cannot travel, such as a toast built from JSON.
  • sticky: true keeps a toast until it is dismissed or its action is pressed (any timeout is ignored), without holding the queue up: a toast dispatched meanwhile shows in its place, and the sticky one comes back once the queue is empty. One sticky toast is kept at a time; a newer one replaces it. Use it for a question that must be answered, not for news:
window.dispatchEvent(new CustomEvent('toast', { detail: { type: 'info', title: 'A new version is ready', sticky: true, action: { label: 'Reload', event: 'app:update' } } }))
window.addEventListener('app:update', () => location.reload())
  • description is M3's second line: the container goes from 48px to 68px (SnackbarTokens.TwoLinesContainerHeight), and below medium a two-line snackbar with an action wraps the action under the text.
  • Escape dismisses a snackbar that holds the focus, and Alt+G moves the focus to a snackbar that carries an action from wherever the page had it — M3 asks the web for a documented shortcut of that kind, since a snackbar never takes the focus itself. Say so where your users read about keyboard shortcuts.
  • Hooks: data-toast on the snackbar on screen, data-toast-action on its action button ([data-toast] is absent while nothing shows). Target these in tests, not classes.

<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="Built in" color="primary" solid />, <x-badge value="Pro" outline /> — a status label (not an M3 badge) in the colour's container, in the colour itself (solid, for a label that has to stand out), or a neutral outline edge (the role that has to be seen, not the decorative outline-variant dividers use). color (alias tone): error default, primary, secondary, tertiary, success, warning, info, neutral, plain; an unknown colour is error.
  • color="neutral" — neutral ink on every variant: a dot or count in on-surface-variant with surface text, tonal in surface-container-high with on-surface-variant text, outline in the outline edge with on-surface-variant text.
  • color="plain" — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: <x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />. Pass both a background and a text class; an outline badge's edge takes the text colour unless you pass a border-* colour.
  • The value is value or the slot; the slot renders as HTML: <x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>. value is escaped. A slot that holds only whitespace or comments is still a dot.

<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. Every alert is role="status", whatever its colour: it is usually on the page as it renders, and an assertive region talks over the page title on load. Pass assertive for one put on screen in answer to something the person just did.

<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). The trigger is pointed at the bubble with aria-describedby, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' wire:click included. 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.

The illustration slot draws the application's own artwork in place of the shape and icon (icon and shape are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its class sets the colour currentColor takes. Mark decorative SVG aria-hidden="true". A slot holding only whitespace or comments leaves the shape and icon.

<x-empty-state title="No routes yet" description="Draw one on the map.">
    <x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
    <x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
</x-empty-state>

<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. A row answers with the state layer and one step of elevation; its corner does not move.

<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>

data-dragged on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires.

That is M3's non-actionable card with actionable elements: Tab walks the controls inside. For M3's directly actionable card, where Tab lands on the card and then moves to the next card, add actionable (and role="link" where it goes somewhere) and put tabindex="-1" on the opener — the card is then the tab stop, named by its title, and Enter or Space on it reaches the opener while the card's other actions follow it.

<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 / video / 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, and a three-line item top-aligns as M3 asks. Its icons are 24px, 20px in a segmented list.

video is M3's leading media in landscape: a poster URL, or <x-slot:video> for a <video> or a thumbnail with a play badge. It is drawn 100×56px in a two-line item and 114×64px in a three-line one (ListTokens' small and large leading video), and always lifts the item to at least the 72px two-line height, since the tallest element sets an item's height.

A list a person chooses from is <x-list selectable> (or selection="single" / selection="multi"): M3 maps those to a list box of options, so the container becomes role="listbox" (aria-multiselectable when multi) and each item an option announcing aria-selected. A selected option also draws a trailing check — M3 never allows colour as the only cue — which icon-right or a leading checkbox replaces. A plain list stays role="list", where selected is aria-current. disabled renders no link and announces aria-disabled.

<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-divider text="Earlier this week" /> is M3's divider with a subheader, to head a group in a list or a menu: the label (title-small, on-surface-variant) at the start, the rule 4px after it and 8px short of the end, 8px under the row. The words stay text; only the rule is the separator (decorative hides just the rule). Horizontal only.

<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.

Bind the open state to a boolean, both ways, with wire:model (any modifiers; .live sends each toggle at once) or x-model:

<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>

Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With wire:model the server renders it open or closed as the property is, so there is no flash, and open is ignored; with x-model, open is only the first paint until Alpine starts. The bound state is collapseOpen in the <details> scope. Without a binding there is no Alpine on it.

<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 (draw the dividers under the headline and above the actions always, not only while the body scrolls), persistent (no Escape or scrim), fullscreen (whole screen on a compact window, below medium, for forms — M3 allows a full-screen dialog only there), alert (role="alertdialog" for a dialog that interrupts to say something important — not for forms), box-class. The headline and the action row are pinned and only the body between them scrolls, as M3 requires, so do not put overflow on box-class. Every dialog divides a scrolling body from them by itself: a 1px outline-variant rule under the header once the body is scrolled away from its top, and one over the actions while more is below (under the close-and-title bar on a phone for fullscreen); a body that fits shows neither, and nothing moves when one appears. So do not add an <x-divider> at the top or bottom of the body, and do not wrap the body's content in a scroll container of its own — the rules follow the body's scroll, and content a Livewire render adds updates them. 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 (default true — M3 requires a close affordance; :with-close-button="false" is ignored when Escape or the scrim is off, and on a pane), close-on-escape (default true), without-backdrop-close, actions slot (left-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). pane (with pane-width, 22.5rem — M3's 360dp fixed pane) turns it into a second pane from expanded, where M3 shows two panes: render it after the list inside <div class="expanded:flex expanded:items-start expanded:gap-6">. Escape leaves a pane open unless pane-close-on-escape. Its body is a size container — lay out inside with @md: (a container query), never a window class.

standard is M3's other side-sheet variant, and it is not the same thing as pane. A pane is the list-detail companion: it shows what the list beside it has selected, 360dp wide on surface-container with a large corner. A standard sheet is supplementary content beside the primary content — filters, details, a list of actions: co-planar from expanded, flat on surface with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below expanded it is the modal sheet. Capped at M3's 400dp whatever width says, and it always draws the close button. Render it in the same expanded:flex row as a pane.

<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 (50dvh — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), heights/snap, actions slot.

heights gives it M3's preset heightsheights="25dvh,50dvh,90dvh", :heights="[25, 50, 90]" (a bare number is dvh) or a JSON list; snap is the shorthand for those three. The sheet then takes its stop's height and opens at the stop that equals height, or at the first. The drag handle is the height control M3 requires beside the drag: activating it (press, Enter, Space) moves to the next stop and announces it, and from the last stop it closes the sheet; dragging settles on the nearest stop, or closes below the smallest. Fewer than two stops is no stops — use height for a single height.

<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, multi-aspect, full-screen — one edge-to-edge item at a time scrolled vertically, which M3 gives to compact and medium windows in portrait only, and never to landscape), 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, 16 — M3's specs table; leading only for uncontained, none for full-screen), 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; aspect is its ratio in a multi-aspect carousel. A region of slide groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a wire:key in a loop.

layout="multi-aspect" is M3's uncontained multi-aspect-ratio layout (November 2025): each <x-carousel-item aspect="16/9"> keeps its own ratio at the row's height, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM.

<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). The set is one tab stop: the arrow keys move between the chips, Home and End go to the ends.

With scroll, M3's overflow affordance is drawn for you: the edge the row can still scroll towards fades, and where the pointer is fine (a mouse, no swipe to reach for) a small button sits over each fading edge and scrolls the row by most of its width. The buttons are pointer-only — not tab stops — because the arrow keys already walk every chip and scroll each one clear of both the fade and the buttons.

<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 all but <x-file> a hint-class, classes added to the hint: hint-class="text-warning"), and read their errors from the bag under the wire:model name, or the name in a plain form (photos[]photos, address[city]address.city); the error replaces the hint, sets aria-invalid, and puts an error icon at the end of the row as M3's second indicator (not on size="xs", and not when the field already trails something — icon-right, clearable, copyable). 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), counter, 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; counter.
  • Width: M3 asks that a text field never span the full width of a large screen, so from medium (600px) every field stops at 40rem; below that it fills its pane. A max-w-* class on the component narrows or widens it, and full (on <x-field>, <x-input>, <x-textarea>) takes the bound off for a field that really is the width of its pane — a search row, an editor. <x-search>'s bar carries M3's own bound, 720px.
  • counter (on <x-input> and <x-textarea>) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: n/max, counted on every keystroke against the field's own maxlength, and in the error colour once the value is past it. It needs maxlength — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
  • <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). Grouping is yours: from expanded (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in <div class="grid gap-4 expanded:grid-cols-2"> (or a card or side sheet) under a heading that names what the group asks.
  • <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. M3 stacks radios and cautions against a row at any width, so reach for inline only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
  • <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), and so does an arrow pressed while Space is held (M3's large interval). 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
orientation horizontal vertical stands it up: the value grows upwards, the value label sits beside the handle, Up and Down move it. Ignored with range — M3 keeps range sliders horizontal
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.

A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so give it a heightclass="h-64", which the label and hint share. Without one it is 192px long.

<x-slider label="Volume" orientation="vertical" wire:model.live="volume" class="h-64" />

<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" clearable />
<x-datepicker label="Race day" wire:model="raceDay" :week-start="$user->week_start" :format="$user->date_format" />
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 on a compact window (below medium), as M3 asks; 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. On a compact window (below medium) it opens as M3's full-screen range picker: an app bar with a close button and Save, the range as the headline, and the months in one scrolling list instead of stepped one at a time
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)
clearable false a button that empties the field (both ends of a range) once it holds a date
week-start null the first day of the week, 0 (Sunday) to 6 (Saturday), instead of the locale's: the calendar's columns, weekday header and Home/End follow it. Anything else is ignored
format null the typed and displayed format instead of the locale's: dd, MM and yyyy, each once, around one delimiter (., /, -) — dd.MM.yyyy, dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd. The field, the dialog's text fields, a range and the error message follow it; wire:model still stores Y-m-d. Anything else is ignored

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() (the last two unless week-start and format say otherwise — for a per-person setting). 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 medium (600px) over a scrim, full screen with a back arrow on a compact window (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. The results are a list and a live region says how many there are. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: placeholder ("Search"), label, icon, trigger; trailing slot (avatar, icon buttons) and suggestions slot.

<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. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying style="--search-width: 22.5rem".

  • trigger="icon" is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so docked does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit.
  • The suggestions slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such.
<x-search trigger="icon" label="Search shares" wire:model.live.debounce.300ms="query">
    <x-slot:suggestions>
        @foreach ($this->recent as $term)
            <x-list-item :title="$term" icon="history" wire:click="$set('query', '{{ $term }}')" wire:key="recent-{{ $term }}" />
        @endforeach
    </x-slot:suggestions>
    @foreach ($this->results as $share)
        <x-list-item :title="$share->name" :link="route('shares.show', $share)" wire:key="result-{{ $share->id }}" />
    @endforeach
</x-search>

Layout

M3's layout vocabulary as components (M3 foundations § Layout: scaffold, bars, rails, panes, margins, spacers, and the canonical layouts). <x-scaffold> holds the bars, the rail and the FAB around the page; all content lives in panes, <x-pane>; two panes side by side are the canonical layouts <x-list-detail> and <x-supporting-pane>, and <x-feed> is the third; <x-surface> is a tonal region; inside a pane, <x-stack>, <x-row> and <x-grid> arrange. There is no "page" component in M3 — a page is a pane. Their stylesheets are resources/css/layout/*.css, all imported by resources/css/layout.css; the list-detail's focus handling is resources/js/layout.js, in material.js.

Breakpoints are M3's five, in px: compact below 600, medium 600, expanded 840, large 1200, extra-large 1600. Every layout component takes:

  • as: the element, div unless the component says otherwise — section, article, aside, main, nav, header, footer, ul, ol, li, dl, form, fieldset, figure, span, p; anything else draws the default.
  • hide-below / hide-from: medium, expanded, large or extra-large. Hidden on a window narrower than that breakpoint, or from it on, over the component's own display (the material.visibility layer). Nothing is below compact, so neither takes compact.
  • gap and padding take only a spacing token's name, space25space900; any other value is no gap or no padding.
  • The caller's class and style land on the root untouched, and an application's own CSS outranks every package rule.

M3's margin — 16px on a compact window, 24px from medium — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an <x-surface>, a new edge, draws it again.

<x-pane>

A content region: M3 puts all content in panes, and each may carry its own top app bar.

<x-pane title="Settings" subtitle="Your account" width="narrow">
    <x-slot:actions><x-button icon="help" tooltip="Help" /></x-slot:actions>
    <x-slot:navigation><x-section-nav :items="$sections" /></x-slot:navigation>

    …
</x-pane>
  • The body keeps M3's margin (16px below medium, 24px from it) unless something around it already does — the scaffold's content region, a canonical layout, another pane's body; on an <x-surface> it keeps it again.
  • width: full (default, the room it has), narrow (40rem, M3's 4060 characters a line), medium (60rem), wide (80rem); capped widths are centred.
  • The app bar is an <x-app-bar>, a direct child of the pane so it spans the pane and stays sticky for its height; it is drawn when there is a title, actions, a leading slot or back. title, subtitle, heading (h1 default — the second pane of a canonical layout passes h2), sticky (default true), the actions slot.
  • The bar's leading button: the leading slot, or back — a URL makes it a link, true calls back() from <x-list-detail> around it (hidden there where both panes show). The arrow mirrors in a right-to-left document.
  • navigation is the pane's section navigation (<x-section-nav>, <x-tabs>), under the bar and above the body with the body's margins — not the bar's leading button.

<x-list-detail>

M3's list-detail canonical layout, for parent and child content: an inbox and a message, folders and a file, settings and a category. list and detail slots.

Breakpoint Visible panes
Compact, below 600px 1: the list, or the detail once something is selected, with a back button
Medium, 600839 1, as compact (M3's recommendation)
Expanded, 8401199 2: the list 360px, the detail the rest, 24px apart, no back button
Large and extra-large, from 1200 2: the list 412px
<x-list-detail wire:model.live="messageId">
    <x-slot:list>
        <x-pane title="Inbox">
            <x-list>
                @foreach ($messages as $message)
                    <x-list-item :title="$message->subject" :link="route('messages.show', $message)" no-wire-navigate wire:click.prevent="$set('messageId', {{ $message->id }})" :selected="$message->id === $messageId" wire:key="message-{{ $message->id }}" />
                @endforeach
            </x-list>
        </x-pane>
    </x-slot:list>
    <x-slot:detail>
        <x-pane :title="$current?->subject" heading="h2" back>…</x-pane>
    </x-slot:detail>
</x-list-detail>
  • selected is what is selected: bound with wire:model (the server renders the right pane from the property, so the first paint is right) or x-model, or given once. Nothing selected is null, false or ''; 0 is an id. Inside both slots selected is in Alpine scope, and back() clears it (a boolean to false, anything else to null). An item selects with wire:click.prevent="$set('messageId', 7)" or x-on:click.prevent="selected = 7" on a list item whose link (with no-wire-navigate) opens the same detail without script, so the keyboard reaches it.
  • The back button: <x-pane back> in the detail slot puts it in that pane's app bar; without one, the layout draws its own row above the detail. Hidden from expanded either way.
  • Focus: below expanded, selecting moves focus to the detail pane and back() returns it to the item it came from (or the list's aria-current/aria-selected item, or the list). From expanded focus stays where it is. Mark the selected item (:selected, aria-current) — M3 shows a selected state in the list where both panes show.
  • A right-to-left document puts the list on the right. The root's attributes belong to Alpine (wire:ignore.self); the slots morph as usual.

<x-surface>

A tonal region: <x-surface level="surface-container-low" padding="space300" corner="lg" outlined>…</x-surface>.

  • level: surface, surface-dim, surface-bright, surface-container-lowest, surface-container-low, surface-container (default), surface-container-high, surface-container-highest; the text on it is on-surface. A higher container step reads as nearer — how M3 separates regions without a shadow.
  • padding: a spacing token. corner: none, xs, sm, md, lg, lg-increased, xl, xl-increased, xxl, full. outlined: M3's 1dp outline-variant edge.

<x-stack>

Children one under another inside a pane: <x-stack gap="space200">…</x-stack>. align across it: stretch (default), start, center, end.

<x-row>

Children side by side inside a pane: <x-row gap="space100" justify="between" stack-below="medium">…</x-row>.

  • align across it: center (default), start, end, stretch, baseline. justify along it: start (default), center, end, between. wrap lets them wrap.
  • stack-below: medium, expanded, large or extra-large — below that breakpoint the row is a column, its children stretched unless align was given.
  • It runs in the inline direction, so it mirrors in a right-to-left document by itself.

<x-grid>

Children in columns inside a pane.

<x-grid :columns="['compact' => 1, 'medium' => 2, 'expanded' => 3]" gap="space300">…</x-grid>
<x-grid min-item="280px" gap="space200">…</x-grid>
  • columns: a map from breakpoint to count, or one number for all. A breakpoint left out takes the nearest smaller one's count; compact is 1 unless given. Each grid writes all five (--md-columns-compact--md-columns-extra-large), so a grid inside another never inherits its parent's.
  • min-item fills each row with as many columns as fit at that width — following the grid's own width, the choice inside a pane narrower than the window. With columns too, the counts are a ceiling.
  • Children keep their source order. M3 publishes no column table for the web; choose counts by the content.

<x-app-shell>

The adaptive app shell, a whole layout's body: one navigation per M3 window size class, 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>.

Window size class Width Navigation Margin
Compact below medium (600px) navigation bar, pinned to the bottom; the rest in the modal rail, opened by $store.rail.show() 16px
Medium medium 600839 collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim 24px
Expanded expanded 8401199 standard rail in the layout, collapsed; the menu button expands it in place, no scrim 24px
Large, extra-large large from 1200 the same standard rail, expanded to begin with 24px

A visitor who has pressed the menu button keeps that choice in both standard bands ($store.rail, remembered and applied before the first paint).

<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>
        <x-fab label="New share" icon="add" link="{{ route('upload') }}" />
    </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="medium: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 page's, also during a Livewire update request), badge (true for a dot, or a count), badgeLabel (what a screen reader hears for the badge: "3 unread"), 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: banner (a bar across the whole window, above the rail and the page), 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 page's own bar, above the page and beside the rail), and the page. label names the landmarks ("Main"); rail-width is the expanded width (16rem); tall-bar picks M3's 80px navigation bar over the 64px one; hide-bar-on-scroll lets the bar leave the window while the page scrolls down; hide-rail-when-collapsed takes the rail out of the layout from expanded when its menu button collapses it, instead of narrowing it to 96px — the only way back is $store.rail.show(), so the app bar then needs a menu button at every width.
  • banner or top: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in banner and the rail starts under it; a bar that titles the page goes in top, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (style="--material-banner: 4rem" on <x-app-shell>), so the rail sticks under it instead of behind it.
  • The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window 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 medium).
  • --material-margin is M3's window margin (16px compact, 24px from medium) and the content region already carries it, so a page inside the shell writes no gutters of its own; something that must reach the window's edges opts out with -mx-(--material-margin).
  • Two panes side by side are M3's from expanded: <x-drawer pane> is the second one, in an expanded:flex expanded:items-start expanded:gap-6 row inside the page.
  • On a compact window the shell sets --material-bottom-bar (the bar, the bottom safe area and --material-bottom-extra), so the snackbar, a fab button and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
  • The content region is max-expanded: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 medium: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"), tall (M3's 80px container, which keeps the icon over the label at every width — <x-app-shell tall-bar> picks it, and the bottom offset grows with it), hide-on-scroll (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. <x-app-shell hide-bar-on-scroll> picks it, and --material-bottom-bar goes down and comes back with the bar, so a fab button and the snackbar keep their distance from it). <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 label="New share" icon="add" /></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, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at medium, a standard rail from expanded — collapsed there, expanded from large).
  • Props: label ("Main"), width (expanded width, 16rem, held between 220 and 360px — or the word narrow for M3's other collapsed width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), align (top default, or center for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), hide-when-collapsed (M3's immersive expanded behaviour, collapsible and adaptive only: collapsing the rail takes it out of the layout instead of narrowing it, and $store.rail.show() brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below medium for a collapsible rail nor at medium for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), menu (the menu button; on by default for collapsible, modal, adaptive), divider (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), fill (false for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: brand (beside the menu button, expanded only), header (one <x-fab label icon>, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), footer. In a flex row the rail sticks to the top of the viewport.
  • Anything else 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.
  • A collapsible rail is held to the collapsed 96px below medium (600px), where M3 says to use a navigation bar rather than a standard rail. collapsed and expanded are fixed-width by design: wrap one in a medium: element if it must not show on a phone.
  • <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; auto is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), 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>

:actions also takes a list, most used first; each entry takes <x-menu-item>'s props (label, icon, link, external, no-wire-navigate, disabled, selected) and passes every other key (wire:click, x-on:click) through as an attribute. The list overflows as M3's trailing actions do: at most two icon buttons below medium (600px) and four from it, the more_vert "More options" button counted among them, and the rest in a menu behind it. Pure CSS — both forms render, each width hides one — so no flash and no script. Every icon button is named and tooltipped by its label. Only the list overflows: markup in the actions slot is drawn as written, and a slot replaces a list. M3 still prefers a toolbar to an app bar full of actions.

<x-app-bar title="holiday-photos" :actions="[
    ['label' => 'Share', 'icon' => 'share', 'wire:click' => 'share'],
    ['label' => 'Download', 'icon' => 'download', 'link' => route('shares.download', $share)],
    ['label' => 'Rename', 'icon' => 'edit', 'x-on:click' => 'renaming = true'],
]" />

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 takes an <x-fab> — beside a floating toolbar, or at the end of a docked one, where the controls gather at the start and the FAB rests flat on the bar (M3's elevation 0 for a nested FAB) and the arrow keys reach it; 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>

A docked toolbar and a navigation bar occupy the same screen region and must never be on screen together: show the bar on a primary page and the toolbar on a secondary or contextual one. A place="bottom" toolbar clears --material-bottom-bar if a bar is there anyway, so nothing is buried.

Large screens: rounded gives a docked toolbar M3's web/large-screen form — from expanded (840px) fully rounded and spanning its container (at place="bottom" it lifts 16px off the window's edges); below expanded it stays the square full-width bar M3 requires. Divide groups of controls with <x-divider vertical /> (<x-divider /> in a vertical toolbar); inside a toolbar it stands as tall as the icon buttons. A floating toolbar is fully rounded already and has no large-screen form: M3 lets it show more controls there, or splits the actions into two toolbars at opposite edges.

<x-toolbar variant="docked" rounded label="Text formatting">
    <x-button icon="undo" tooltip="Undo" wire:click="undo" />
    <x-button icon="redo" tooltip="Redo" wire:click="redo" />
    <x-divider vertical />
    <x-button icon="format_bold" tooltip="Bold" :selected="$bold" wire:click="toggleBold" />
    <x-button icon="format_italic" tooltip="Italic" :selected="$italic" wire:click="toggleItalic" />
</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 medium (600px), a menu picker on a compact window, whose items mark the current section as the page (current) and carry each section's badge. Up to four sections share the row; from five it is M3's scrollable tab bar — tabs as wide as their labels, offset 52dp from the leading edge so it reads as scrollable. items: ['title', 'url', 'icon', 'active', 'badge'] — current when active or its url is the page's (during a Livewire update request, the page the component was rendered on, so the section stays lit when a component re-renders). 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 (a row of three for settings pages), contrast (the same row for M3's standard, medium and high levels, marking the one in force). Every toggle on a page shares the store. Both rows are <x-group> — a connected button group over native radios, so the arrow keys, the wrap and the roving tab stop are the browser's; label adds a visible legend, and without one the row is still named for a screen reader. Nothing is checked until Alpine has read the store, because the theme is known only in the browser.

<x-scheme-picker>

A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour, at the contrast level on screen — over native radios. wire:model or x-model (with name) binds the chosen name; choosing previews it on the page at once; storing it is the application's. label, hint, name, profiles (default Scheme::profiles()). A validation error for the bound property replaces the hint. Without profiles it renders nothing.

<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />

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

A data table: write plain <thead>, <tr>, <th>, <td> inside <x-table>; cell utilities (text-end, whitespace-nowrap) always win. Rows are 52px — a target a finger can hit. dense tightens them to 36px and size="xs" is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. 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
        ->forbidAbsolutes()                    // opt-in: `bg-white`, `text-black`
        ->forbidOpacityInk()                   // opt-in: `text-on-surface/60`
        ->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.

It also fails on every value the theme cleared, and names the replacement on the same line:

Written Use
sm:, md: (and max-sm:, max-md:) medium: (max-medium:)
lg:, xl:, 2xl: expanded:, large:, extra-large:
rounded-lg, rounded-t-2xl, rounded-full rounded-corner-lg, rounded-t-corner-xxl, rounded-corner-full
shadow-sm, shadow-mdshadow-2xl shadow-elevation-1shadow-elevation-5
text-sm, leading-6, tracking-wide a type-* style, which sets the three together
font-medium, font-bold a type-emphasized-* style
ease-in-out, ease-linear ease-standard, or an ease-spatial-*/ease-effects-*
duration-300 duration-(--md-sys-motion-…-duration), paired with its easing
bg-[#1d7afc], text-[rgb(…)], border-[color-mix(…)] an M3 role

forbidAbsolutes() adds white and black (M3's white is surface-container-lowest, its ink an on- role) and forbidOpacityInk() adds opacity as emphasis (text-on-surface/60text-on-surface-variant or text-outline). Both are off by default: M3 reserves 38 % on content and 12 % on a container for the disabled state, and the package's own components are written with those two opacities.

Conventions

  • Components are anonymous Blade components: <x-name> without a prefix, or <x-{prefix}::name> when config('livewire-material.prefix') is set; <x-livewire-material::name> always works.
  • 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-medium: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.