Move onto Livewire Material 2.0.0 and leave Tailwind behind
Livewire Material 2.0.0 aligns every component with Material 3 Expressive and carries no Tailwind anywhere, so SealShare drops tailwindcss and its Vite plugin and writes its views in the package's vocabulary: layout components (<x-pane>, <x-stack>, <x-row>, <x-grid>, <x-form>) with M3's spacing tokens, the md-type-*/md-ink-* text classes, and --md-sys-* tokens in its own small stylesheet. - resources/css/app.css opens with the package's layer order, imports foundation.css and the stylesheet of each component the views render, then the scheme, regenerated with the 2025 colour rules at M3's three contrast levels. The app's own rules follow, one section per view, on tokens and on M3's breakpoints (600/840/1200/1600px) only. - Every view is rewritten in that vocabulary while SealShare keeps the shape it had: the admin table, the admin settings, the recovery codes, the share options and the download page are cards, and their fields fill them rather than stopping at the 40rem bound a card already bounds. The user settings pages became cards too, to match the admin's, each with the sections that stand apart from its one subject — deleting the account, the recovery codes — in a card beside it. Material 3 decides how a component behaves, not whether a container survives: buttons keep their label's width, a form's actions end it, and each heading level keeps one type role. - The layouts clear the floating toolbar by the --material-bottom-toolbar the package publishes, and the snackbar clears it by itself. - The two-factor setup QR code comes from QrCodeService, so it keeps a white field and quiet zone in the dark theme and still scans. - Browse Files is a real button that opens the file input, reachable and visibly focused from the keyboard. - Tests: the design test scans views, JS, CSS and app/ and checks the CSS entry both ways (no missing, no unused import). New Chromium suites cover the frame, settings and admin, the share flow, and every page at M3's breakpoint edges (599/600, 839/840, 1199/1200, 1600px). The package's renamed data-md-* hooks replace the 1.x ones. - Boost's update brings the material-3 guideline and skill and drops the Tailwind skill. composer.json requires nonameweb/livewire-material ^2.0 from the Gitea repository, resolved at the 2.0.0 tag. The CHANGELOG records the move as 2.1.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
461bc23f0a
commit
a88a052d9a
@@ -15,13 +15,15 @@ Composer packages must be installed before the Vite build (in Dockerfiles and CI
|
||||
|
||||
```css
|
||||
/* resources/css/app.css */
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/scaffold.css';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
|
||||
@import './material-scheme.css';
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
```
|
||||
|
||||
`foundation.css` is required and first; then one stylesheet per component the views render, under `resources/css/components/` or `resources/css/layout/`, named like the component (`<x-tab>` is drawn by `tabs.css`, and `<x-theme-script>` needs none). Each imports the stylesheets of the components it draws (a split button's button and menu), and Vite keeps a file several of them import once. `resources/css/all.css` stands for the foundation and every component at once. A component rendered without its stylesheet is unstyled, so add the import with the tag.
|
||||
|
||||
```js
|
||||
// resources/js/app.js
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
@@ -44,7 +46,18 @@ The scheme is generated, never hand-edited. Regenerate it with the seed and vari
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
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
|
||||
|
||||
@@ -62,6 +75,7 @@ An installation that switches between several schemes lists them in `config/live
|
||||
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:
|
||||
|
||||
@@ -71,26 +85,41 @@ 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()))`.
|
||||
- `<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
|
||||
|
||||
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.
|
||||
2.0.0's vocabulary is plain CSS, without Tailwind. `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). 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, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token (spacing included: `--md-sys-measurement-space25` … `space900`, 2–72px), 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-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token. Every component is plain CSS now; the Tailwind names below stay for the showcase and an application's own views, until Tailwind itself leaves the package.
|
||||
|
||||
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-…`. Never assemble `text-*`, `leading-*` and `tracking-*` by hand. The font is Google Sans Flex (`font-sans`).
|
||||
- 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). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations.
|
||||
- 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.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
`config/livewire-material.php` → `theme.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; so does an `<x-fab>` in `<x-scaffold>`'s `fab` slot, which places it.
|
||||
|
||||
`--material-bottom-toolbar` is the distance from the window's bottom edge to the top of a toolbar at `place="bottom"`, unset while there is none: the snackbar clears it, and a page without `<x-scaffold>` pads its end with it (see `<x-toolbar>`).
|
||||
|
||||
`--material-bottom-extra` (default `0px`) is the height of anything the application docks on top of the phone's navigation bar in `<x-scaffold>` (an offline banner): the scaffold adds it to `--material-bottom-bar` (64px + the bottom inset), so the snackbar, a `fab` button, the scaffold's FAB 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
|
||||
|
||||
@@ -117,7 +146,7 @@ The methods are protected. They dispatch a `toast` browser event (`assertDispatc
|
||||
|
||||
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`.
|
||||
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme, and inline the error layout's own stylesheet beside them. The application imports nothing for them: no `error-page.css` in the CSS entry (the design guard's `missingStylesheets()` does not ask for it, and an entry that imports it through `all.css` does no harm). 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`:
|
||||
|
||||
@@ -170,15 +199,20 @@ Your export is ready.
|
||||
|
||||
### `<x-icon>`
|
||||
|
||||
A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
|
||||
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 |
|
||||
| `size` | `24` | the drawn size in px, a whole number from 8 to 256 (`16`, `18`, `20`, `32`, `40`, `48` …), written as `--md-icon-size` |
|
||||
| `optical` | from `size` | 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` |
|
||||
| `mirror-rtl` | `false` | flips a directional symbol (an arrow, a chevron) in a right-to-left document |
|
||||
|
||||
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" />`.
|
||||
Colour follows the text: `<x-icon name="lock" size="20" class="md-ink-variant" />`.
|
||||
|
||||
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 takes the 20 cut**; scaling the 24 cut down thins its strokes by about a sixth. `size="20"` and below pick it for you. Pass `optical="20"` yourself only when the icon's size comes from somewhere else (your own CSS setting its width and height, which outranks the package's layer).
|
||||
|
||||
### `<x-shape>`
|
||||
|
||||
@@ -186,7 +220,7 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
|
||||
|
||||
### `<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`.
|
||||
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>`
|
||||
|
||||
@@ -200,13 +234,13 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
| `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 |
|
||||
| `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 `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||||
| `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 |
|
||||
|
||||
```blade
|
||||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||||
@@ -216,7 +250,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||||
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>`
|
||||
|
||||
@@ -235,11 +269,55 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
`<x-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:
|
||||
|
||||
```blade
|
||||
<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:
|
||||
|
||||
```blade
|
||||
<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.
|
||||
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`:
|
||||
|
||||
```blade
|
||||
<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>`
|
||||
|
||||
@@ -253,7 +331,7 @@ A choice between a few options as a connected button group of native radios (che
|
||||
]" hint="Recipients lose access after that" />
|
||||
```
|
||||
|
||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
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>`
|
||||
|
||||
@@ -267,12 +345,14 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
|
||||
`<x-fab 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: put the page's FAB in `<x-scaffold>`'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `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>`
|
||||
|
||||
```blade
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<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') }}" />
|
||||
@@ -280,7 +360,7 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
||||
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>`
|
||||
|
||||
@@ -302,7 +382,19 @@ The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`po
|
||||
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
|
||||
```
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
`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:
|
||||
|
||||
```js
|
||||
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-md-toast` on the host, `data-md-toast-snackbar` on the snackbar on screen (absent while nothing shows), `data-md-toast-action` on its action button and `data-md-toast-dismiss` on its close button. Target these in tests, not classes.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
@@ -334,7 +426,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
- `<x-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>`
|
||||
|
||||
@@ -348,7 +443,7 @@ A notice in the page, in the state's container colour with its icon:
|
||||
</x-alert>
|
||||
```
|
||||
|
||||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
|
||||
`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>`
|
||||
|
||||
@@ -360,7 +455,7 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `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>`
|
||||
|
||||
@@ -370,22 +465,39 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||
|
||||
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
|
||||
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.
|
||||
|
||||
```blade
|
||||
<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-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`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Do not pass `bg-*`; use `variant`.
|
||||
|
||||
A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-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.
|
||||
|
||||
```blade
|
||||
<x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<a href="{{ route('shares.show', $share) }}" data-md-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-md-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` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
|
||||
`<x-list>`: `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`.
|
||||
|
||||
```blade
|
||||
<x-list segmented label="Files">
|
||||
@@ -401,10 +513,21 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
|
||||
|
||||
`<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`:
|
||||
|
||||
```blade
|
||||
<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.
|
||||
@@ -418,15 +541,19 @@ An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; cl
|
||||
</x-modal>
|
||||
```
|
||||
|
||||
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
|
||||
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`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `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). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. 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: 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 beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
|
||||
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 heights** — `heights="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>`, `<x-carousel-item>`
|
||||
|
||||
@@ -440,7 +567,9 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
|
||||
</x-carousel>
|
||||
```
|
||||
|
||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||
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>`
|
||||
|
||||
@@ -476,11 +605,13 @@ Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `d
|
||||
|
||||
### `<x-chip-set>`
|
||||
|
||||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards).
|
||||
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.
|
||||
A one-column grid of fields with an `actions` slot at the foot, end-aligned (the slot takes its own `class`); `separator` draws a divider above the actions. Put a form's buttons in `actions`; a button written among the fields keeps its label's width at the start edge rather than stretching across.
|
||||
|
||||
```blade
|
||||
<x-form wire:submit="save">
|
||||
@@ -495,21 +626,23 @@ A one-column grid of fields with an `actions` slot at the foot (the slot takes i
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
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), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<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.
|
||||
- `<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-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have.
|
||||
|
||||
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
||||
|
||||
M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
|
||||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group.
|
||||
- `<x-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`.
|
||||
|
||||
```blade
|
||||
@@ -519,7 +652,7 @@ M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
### `<x-slider>`
|
||||
|
||||
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
|
||||
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.
|
||||
|
||||
```blade
|
||||
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
|
||||
@@ -540,10 +673,17 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
|
||||
| `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 height** — `class="h-64"`, which the label and hint share. Without one it is 192px long.
|
||||
|
||||
```blade
|
||||
<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).
|
||||
@@ -552,19 +692,22 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
<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 below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||||
| `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()`. 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.
|
||||
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>`
|
||||
|
||||
@@ -604,7 +747,7 @@ Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options
|
||||
|
||||
### `<x-search>`
|
||||
|
||||
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||||
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.
|
||||
|
||||
```blade
|
||||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||
@@ -615,47 +758,202 @@ M3 search bar that opens into a search view: docked under the bar from `sm`, ful
|
||||
</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 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"`.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
- `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.
|
||||
|
||||
```blade
|
||||
<x-app-shell :destinations="[
|
||||
<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`, each imported directly by `resources/css/all.css` (or by whichever component stylesheets draw with them, `foundation.css`'s way); 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, `space25` … `space900`; 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-scaffold>`
|
||||
|
||||
The adaptive scaffold, a whole layout's body: one navigation per M3 breakpoint, the page as `<main id="content" wire:transition.navigate>` behind a skip link, the page's FAB, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`. It was `<x-app-shell>` before 2.0.0; that name is gone.
|
||||
|
||||
| Breakpoint | 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` 600–839 | collapsed rail (96px) in the layout, no bar; its menu button opens it expanded over a scrim | 24px |
|
||||
| Expanded | `expanded` 840–1199 | 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).
|
||||
|
||||
```blade
|
||||
<x-scaffold :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
<x-slot:fab>
|
||||
<x-fab icon="add" tooltip="New share" link="{{ route('upload') }}" />
|
||||
</x-slot:fab>
|
||||
|
||||
{{ $slot }}
|
||||
</x-app-shell>
|
||||
</x-scaffold>
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
- `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 that lives in the rail), `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), `fab` (the page's FAB) 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.
|
||||
- `fab` places an `<x-fab>` as Compose's Scaffold does: fixed at the bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`, above the navigation bar and the bottom safe area, and lifted above a snackbar while one shows (M3: a snackbar appears above a FAB, never in front of or behind it). In focus order it comes after the page's bar and before the page. Use it or a FAB in `rail-header`, never both: M3 allows one FAB on a screen.
|
||||
- `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-scaffold>`), 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 scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
|
||||
- On a compact window the scaffold sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, the FAB 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-pane>`
|
||||
|
||||
A content region: M3 puts all content in panes, and each may carry its own top app bar.
|
||||
|
||||
```blade
|
||||
<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 40–60 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, 600–839 | 1, as compact (M3's recommendation) |
|
||||
| Expanded, 840–1199 | 2: the list 360px, the detail the rest, 24px apart, no back button |
|
||||
| Large and extra-large, from 1200 | 2: the list 412px |
|
||||
|
||||
```blade
|
||||
<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-supporting-pane>`
|
||||
|
||||
M3's supporting-pane canonical layout, for content that only means something beside the focus pane: comments on a document, details of a video. For a parent and its children use `<x-list-detail>`. `main` (or the default slot) and `supporting` slots.
|
||||
|
||||
```blade
|
||||
<x-supporting-pane label="Comments" compact="sheet">
|
||||
<x-slot:main><x-pane title="Proposal">…</x-pane></x-slot:main>
|
||||
<x-slot:supporting><x-pane title="Comments" heading="h2">…</x-pane></x-slot:supporting>
|
||||
</x-supporting-pane>
|
||||
```
|
||||
|
||||
- From `expanded` the supporting pane is on the trailing side, 24px from the focus pane: `width="fixed"` (default) is 360px, 412px from `large`; `width="split"` gives the focus pane two-thirds and the supporting pane one-third. Always trailing, so focus order stays the order on screen; a right-to-left document mirrors it.
|
||||
- Below `expanded`: `compact="below"` (default) stacks it under the focus pane; `compact="sheet"` docks it to the bottom of the window as a bottom sheet (surface-container-low, extra-large top corners), shown as its drag handle and `label` until the handle — a button with `aria-expanded` — opens it; open, it scrolls within half the window, and Escape inside it closes it and returns focus to the handle. It clears the navigation bar and the bottom safe area and never covers the end of the focus pane.
|
||||
- `label` names the supporting `<aside>` and the sheet's handle.
|
||||
|
||||
#### `<x-feed>`
|
||||
|
||||
M3's feed canonical layout: cards or items to browse, in columns that multiply as the room grows.
|
||||
|
||||
```blade
|
||||
<x-feed min-item="280px">
|
||||
@foreach ($posts as $post)
|
||||
<x-card wire:key="post-{{ $post->id }}" :title="$post->title">…</x-card>
|
||||
@endforeach
|
||||
</x-feed>
|
||||
```
|
||||
|
||||
- One column below `medium` (M3: one card per row, full width). From `medium`, as many equal columns of at least `min-item` as fit (`240px` default; `280px`, `18rem`, `20ch`, or a number of px) — more on a wider window. It follows the feed's own width, so a feed in a narrow pane stays sensible.
|
||||
- The gap is M3's spacer, 16px below `medium` and 24px from it, unless `gap` names a spacing token.
|
||||
- Items keep their order, which M3 makes the reading order. A card that should span two columns says so in its own `style`.
|
||||
|
||||
#### `<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`. A button keeps its label's width at the start edge even when the rest stretch: M3 keeps a button's width dynamic and never stretches one into a long flat shape. Widen one only in your own CSS, and only with a reason.
|
||||
|
||||
#### `<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.
|
||||
|
||||
```blade
|
||||
<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-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):
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-scaffold>` does):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<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') }}" />
|
||||
@@ -663,7 +961,7 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
|
||||
</div>
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
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-scaffold 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-scaffold 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>`
|
||||
|
||||
@@ -673,7 +971,7 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
<x-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">
|
||||
@@ -689,11 +987,12 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
</div>
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- `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-scaffold>`'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 can take both shapes by matching the rail's own state directly — `data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode collapses in, the same selectors `resources/css/components/navigation-rail.css` uses for every branch of "collapsed" (that file, not this one, is where the numbers live). The Tailwind `rail-collapsed:` variant this used to teach no longer exists: the package's own CSS is plain now. 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), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
- `$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.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
@@ -706,11 +1005,21 @@ M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away),
|
||||
</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.
|
||||
|
||||
```blade
|
||||
<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 sets a FAB beside a floating toolbar; `label` names it.
|
||||
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.
|
||||
|
||||
```blade
|
||||
<x-toolbar label="Selection" place="bottom" vibrant>
|
||||
@@ -720,6 +1029,22 @@ M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `var
|
||||
</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.
|
||||
|
||||
A `place="bottom"` toolbar publishes `--material-bottom-toolbar`: the distance from the window's bottom edge to its top, bottom safe area and navigation bar included. The snackbar lifts above it by itself; pad the end of the page so its last line clears the toolbar: `padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200))`. Never set `--material-bottom-bar` for a toolbar: the toolbar reads that variable to place itself.
|
||||
|
||||
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.
|
||||
|
||||
```blade
|
||||
<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 `id`s.
|
||||
@@ -733,7 +1058,7 @@ M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skip
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
||||
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>`
|
||||
|
||||
@@ -750,11 +1075,11 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
### `<x-theme-toggle>`
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
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 — 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.
|
||||
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.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
@@ -762,7 +1087,7 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
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-md-list-row` with one `data-md-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`.
|
||||
|
||||
@@ -772,8 +1097,8 @@ A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<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>
|
||||
<tr data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<td><a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate>{{ $share->name }}</a></td>
|
||||
<td class="text-end tabular-nums">{{ $share->size }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@@ -791,13 +1116,43 @@ Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor)
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
it('uses only what compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), resource_path('css'), app_path()])
|
||||
->missingStylesheets(resource_path('css/app.css'))
|
||||
->unusedStylesheets(resource_path('css/app.css'))
|
||||
->forbidColours(['tertiary']) // roles this application's rules leave out
|
||||
->violations())->toBe([]);
|
||||
});
|
||||
```
|
||||
|
||||
It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each.
|
||||
It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and every Tailwind utility or variant — none compiles in a Tailwind-free application — or 1.x utility, each with its 2.0.0 replacement. A class the application's own stylesheets declare is exempt, and so is every `md-*` class:
|
||||
|
||||
| Written | Use |
|
||||
| --- | --- |
|
||||
| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` (and `max-`) | `@media (width >= 600px)` …, or a layout component's `hide-below`/`hide-from`/`stack-below` prop |
|
||||
| `hover:`, `dark:`, `group-hover:`, `rtl:` … | `md-state-layer`/`md-focus-ring`, `[data-theme='dark']`, `:has()`, `:is([dir='rtl'], [dir='rtl'] *)` in your own CSS |
|
||||
| `flex`, `flex-col`, `flex-wrap`, `items-center`, `justify-between` | `<x-row>`, `<x-stack>`, `<x-row wrap>`, `align="center"`, `justify="between"` |
|
||||
| `grid`, `grid-cols-3` | `<x-grid :columns="3">` (or a per-breakpoint map), `<x-feed>` for cards |
|
||||
| `gap-4`, `space-y-4`, `p-4`, `mt-2` | the M3 step at 4px per unit: `gap="space200"` (16px), `<x-stack gap="space200">`, `<x-surface padding="space200">`, `var(--md-sys-measurement-space100)` |
|
||||
| `w-full`, `max-w-5xl`, `size-6` | `<x-pane width>`, `<x-icon size>`, or a length in your own CSS — M3 keeps no size scale |
|
||||
| `hidden` | a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute, or `x-show` |
|
||||
| `block`, `relative`, `inset-0`, `z-10`, `overflow-hidden`, `shrink-0`, `border`, `opacity-50`, `cursor-pointer` … | the rule in your own CSS |
|
||||
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
|
||||
| `type-body-md`, `focus-ring`, `state-layer`, `link` (1.x) | `md-type-body-md`, `md-focus-ring`, `md-state-layer`, `md-link` |
|
||||
| `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
|
||||
| `border-outline-variant`, `border-divider` | `<x-divider>` or `<x-surface outlined>` |
|
||||
| `bg-surface-container` | `<x-surface level="surface-container">` |
|
||||
| `bg-primary`, `border-error`, `text-red-500` … | `var(--md-sys-color-*)` |
|
||||
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
|
||||
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
|
||||
| `rounded-lg`, `rounded-corner-lg` (1.x) | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
|
||||
| `shadow-md`, `shadow-elevation-2` (1.x) | `var(--md-sys-elevation-2)` |
|
||||
| `text-sm`, `leading-6`, `tracking-wide`, `font-medium` | one of the `md-type-*`/`md-type-emphasized-*` classes |
|
||||
| `ease-in-out`, `ease-spatial-fast` (1.x), `duration-300` | `var(--md-sys-motion-spatial-fast)` with its `-duration`, in your own `transition` |
|
||||
| `bg-[#1d7afc]`, `w-[320px]`, `[--x:1]` | an M3 role, or the value in your own CSS |
|
||||
|
||||
In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
|
||||
|
||||
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -807,7 +1162,7 @@ It fails on maryUI tags, daisyUI classes, colours the theme does not declare, un
|
||||
|
||||
## Livewire traps
|
||||
|
||||
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot.
|
||||
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-sm:hidden`) is safe.
|
||||
- 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 — `@js($value)` is `{{ \Illuminate\Support\Js::from($value) }}`, `@class([...])` is `:class="\Illuminate\Support\Arr::toCssClasses([...])"` — or put the Alpine on a plain element inside the slot. `DesignGuard` names the form for each directive.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
name: material-3-design
|
||||
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the utility that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
|
||||
---
|
||||
|
||||
# Material 3 design
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
|
||||
|
||||
Every table pairs the M3 name with the library's utility or attribute. The tokens are CSS variables (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`) and the utilities read them, so a template names a utility and never a variable or a value.
|
||||
|
||||
## Colour
|
||||
|
||||
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A template names a role and nothing else — never a hex, a palette tone, an arbitrary value or an opacity — because only a role follows the theme, the contrast level and a colour profile.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Purpose | Its `on-` pair | Utility |
|
||||
| --- | --- | --- | --- |
|
||||
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `bg-primary text-on-primary`, `text-primary` |
|
||||
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `bg-primary-container text-on-primary-container` |
|
||||
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `bg-primary-dim` |
|
||||
| secondary | Less prominent fills, text and icons | on-secondary | `bg-secondary text-on-secondary` |
|
||||
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `bg-secondary-container text-on-secondary-container` |
|
||||
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `bg-tertiary text-on-tertiary` |
|
||||
| tertiary-container | The complementary fill | on-tertiary-container | `bg-tertiary-container text-on-tertiary-container` |
|
||||
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `text-error`, `bg-error text-on-error` |
|
||||
| error-container | An error panel | on-error-container | `bg-error-container text-on-error-container` |
|
||||
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `text-success`, `bg-warning-container text-on-warning-container` |
|
||||
| surface | The page background | on-surface | `bg-surface text-on-surface` |
|
||||
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `text-on-surface-variant`, `text-body`, `text-meta` |
|
||||
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `border-outline`, `text-outline`, `text-quiet` |
|
||||
| outline-variant | Decorative lines: dividers, card edges | — | `border-outline-variant`, `border-divider`, `border-structure`, `border-chrome`, `divide-divider` |
|
||||
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `bg-inverse-surface text-inverse-on-surface` |
|
||||
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `text-inverse-primary` |
|
||||
| scrim | Behind a modal, at 32% | — | `bg-scrim/32` |
|
||||
| shadow | The shadow colour, inside `shadow-elevation-*` | — | — |
|
||||
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `bg-surface-dim`, `bg-surface-bright` |
|
||||
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `bg-primary-fixed text-on-primary-fixed` |
|
||||
|
||||
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. `bg-primary text-on-primary` and `bg-secondary-container text-on-secondary-container` are right; `bg-primary-container text-on-surface` or `bg-secondary-container text-primary` are not, and break as the contrast level rises. Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
|
||||
|
||||
### Surface containers
|
||||
|
||||
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
|
||||
|
||||
| Role | Use | Utility |
|
||||
| --- | --- | --- |
|
||||
| surface | The page | `bg-surface` |
|
||||
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `bg-surface-container-lowest` |
|
||||
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `bg-surface-container-low` |
|
||||
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `bg-surface-container` |
|
||||
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `bg-surface-container-high` |
|
||||
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `bg-surface-container-highest` |
|
||||
|
||||
### Emphasis and lines
|
||||
|
||||
- Default ink is `on-surface`; lower emphasis is `on-surface-variant`; decoration is `outline`. Emphasis is never an opacity: M3 reserves 38% (`/38`) for disabled content and 12% (`/12`) for a disabled container.
|
||||
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
|
||||
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: the `link` utility.
|
||||
|
||||
### Contrast
|
||||
|
||||
| Level | Target | How |
|
||||
| --- | --- | --- |
|
||||
| Standard | Hierarchy from high- and low-contrast elements together; text 4.5:1, large text and icons 3:1, grouped non-text controls 3:1 | the default scheme |
|
||||
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
|
||||
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
|
||||
|
||||
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, `white`, `black`) does not change and is the one thing that breaks a contrast level.
|
||||
|
||||
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
|
||||
|
||||
## Surfaces and elevation
|
||||
|
||||
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
|
||||
|
||||
| Level | Shadow | Rests here | Utility |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
|
||||
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `shadow-elevation-1` |
|
||||
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `shadow-elevation-2` |
|
||||
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `shadow-elevation-3` |
|
||||
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `shadow-elevation-4` |
|
||||
| 5 | 12dp | Interaction only | `shadow-elevation-5` |
|
||||
|
||||
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
|
||||
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
|
||||
- A scrim (`bg-scrim/32`) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
|
||||
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
|
||||
|
||||
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
|
||||
|
||||
## Shape
|
||||
|
||||
### The corner scale
|
||||
|
||||
| Style | Value | Utility |
|
||||
| --- | --- | --- |
|
||||
| None | 0 | `rounded-corner-none` |
|
||||
| Extra small | 4px | `rounded-corner-xs` |
|
||||
| Small | 8px | `rounded-corner-sm` |
|
||||
| Medium | 12px | `rounded-corner-md` |
|
||||
| Large | 16px | `rounded-corner-lg` |
|
||||
| Large increased | 20px | `rounded-corner-lg-increased` |
|
||||
| Extra large | 28px | `rounded-corner-xl` |
|
||||
| Extra large increased | 32px | `rounded-corner-xl-increased` |
|
||||
| Extra extra large | 48px | `rounded-corner-xxl` |
|
||||
| Full | a stadium or circle | `rounded-corner-full` |
|
||||
|
||||
Tailwind's `rounded-sm`, `rounded-lg`, `rounded-full`, … do not compile. The `-t`, `-s`, `-e` sides work (`rounded-t-corner-xl` for a bottom sheet).
|
||||
|
||||
### Corner by component
|
||||
|
||||
| Component | Corner | Note |
|
||||
| --- | --- | --- |
|
||||
| Buttons, icon buttons, split button (outer), FAB menu items | full | a press morphs to `md` (xs/sm sizes), `lg` (md), `xl` (lg/xl); a selected toggle swaps round ↔ square |
|
||||
| Connected button group | full outside, `sm` between segments | segments press to `xs` |
|
||||
| FAB | `lg` 16 (baseline 56px), `lg-increased` 20 (medium 80px), `xl` 28 (large 96px) | extended FAB `lg` |
|
||||
| Chips | `sm` 8 | an avatar in a chip `md` 12 |
|
||||
| Cards | `md` 12 | no change on hover |
|
||||
| Text fields | `xs` 4 (outlined: all corners; filled: top corners only) | |
|
||||
| Menus, snackbar, plain tooltip | `xs` 4 | the Expressive vertical menu rounds the focused item |
|
||||
| Rich tooltip | `md` 12 | |
|
||||
| Dialogs | `xl` 28 | full-screen dialog `none` |
|
||||
| Bottom sheet | `xl` 28 on top | |
|
||||
| Side sheet | `lg` 16 on the inner side | |
|
||||
| Search bar | full | search view `xl` 28 when docked, `none` full-screen |
|
||||
| Date and time pickers | `xl` 28 | date cells full |
|
||||
| Carousel items | `xl` 28 | |
|
||||
| Navigation indicator, badges, switch, slider handle, checkbox state layer | full | checkbox box 2px, tab indicator 3px on top |
|
||||
| Navigation bar, app bar, docked toolbar, tabs | none | floating toolbar full |
|
||||
| Segmented list rows | `xs` inner, `lg` outer; a selected row `lg` | |
|
||||
|
||||
### Rules
|
||||
|
||||
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius − padding (48 − 14 = 34), never the container's own radius.
|
||||
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
|
||||
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
|
||||
- The 35 Expressive shapes (`<x-shape name="…">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
|
||||
|
||||
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
|
||||
|
||||
## Type
|
||||
|
||||
The typeface is Google Sans Flex for brand and plain styles (`font-sans`, `--md-ref-typeface-brand`); an application may replace it after importing the stylesheet. Each style is one utility that sets size, line height, weight, family and tracking together; `text-sm`, `font-medium`, `leading-*` and `tracking-*` do not compile.
|
||||
|
||||
| Role | Style | Size / line | Weight | Utility | Use for |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `type-display-lg` … | hero figures, one short marketing line; never running text |
|
||||
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
|
||||
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
|
||||
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
|
||||
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
|
||||
|
||||
- `type-emphasized-*` is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
|
||||
- Tracking follows Compose's `TypeScaleTokens`: display-large −0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
|
||||
- Line length 40–60 characters (`max-w-prose` is close; `max-w-[60ch]` is exact). Figures that change take `tabular-nums`.
|
||||
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis only when the full text is one tooltip or link away.
|
||||
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
|
||||
|
||||
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
|
||||
|
||||
## Motion
|
||||
|
||||
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
|
||||
|
||||
| Spring | Damping / stiffness | Duration | Utility | For |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Spatial fast | 0.6 / 800 | 350ms | `ease-spatial-fast duration-(--md-sys-motion-spatial-fast-duration)` | small elements: a button's press morph, a switch, a chip |
|
||||
| Spatial default | 0.8 / 380 | 500ms | `ease-spatial-default duration-(--md-sys-motion-spatial-default-duration)` | most position, size and shape changes |
|
||||
| Spatial slow | 0.8 / 200 | 650ms | `ease-spatial-slow duration-(--md-sys-motion-spatial-slow-duration)` | large surfaces: a sheet, a pane, a full-screen transition |
|
||||
| Effects fast | 1.0 / 3800 | 150ms | `ease-effects-fast duration-(--md-sys-motion-effects-fast-duration)` | state layers, small fades |
|
||||
| Effects default | 1.0 / 1600 | 200ms | `ease-effects-default duration-(--md-sys-motion-effects-default-duration)` | most colour and opacity changes |
|
||||
| Effects slow | 1.0 / 800 | 300ms | `ease-effects-slow duration-(--md-sys-motion-effects-slow-duration)` | large fades, a scrim |
|
||||
|
||||
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A `transition-all` mixes the two and is wrong.
|
||||
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
|
||||
- Direction: something entering decelerates (`ease-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`ease-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `ease-emphasized`; exits are shorter than entrances, and larger areas move longer.
|
||||
- The cubic-bezier set (`ease-standard`, `ease-emphasized`, `ease-emphasized-decelerate`, `ease-emphasized-accelerate`, `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
|
||||
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `duration-300` or a keyframe with its own timing ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
|
||||
|
||||
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
|
||||
|
||||
## States and targets
|
||||
|
||||
| State | Layer | Utility or hook | Also |
|
||||
| --- | --- | --- | --- |
|
||||
| Enabled | none | — | |
|
||||
| Hover | 8% of the content colour | `state-layer` (pointer devices only) | one level of elevation on floating elements |
|
||||
| Focused | 10% | `state-layer focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
|
||||
| Pressed | 10% | `state-layer` (`:active`) | the shape morph on buttons |
|
||||
| Dragged | 16% | `state-layer` with `data-dragged` | one level of elevation |
|
||||
| Disabled | content 38%, container 12%, no state layer, not focusable | `disabled:text-on-surface/38 disabled:bg-on-surface/12` | exempt from contrast; a FAB is hidden rather than disabled |
|
||||
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
|
||||
|
||||
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time.
|
||||
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
|
||||
- Targets: 48×48px minimum, 8px between targets, on every device; `touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
|
||||
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
|
||||
|
||||
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
|
||||
|
||||
## Layout and breakpoints
|
||||
|
||||
Layout keys on the width of the window, in M3's five window size classes; they are the only responsive variants that compile, and `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) gives scripts the same numbers.
|
||||
|
||||
| Class | Width | Variant | Navigation | Panes | Dialogs and choices | Margins |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Compact | below 600px | the default; `max-medium:` for "only here" | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
|
||||
| Medium | 600–839px | `medium:` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
|
||||
| Expanded | 840–1199px | `expanded:` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
|
||||
| Large | 1200–1599px | `large:` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
|
||||
| Extra-large | 1600px and up | `extra-large:` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
|
||||
|
||||
- `<x-scaffold>` implements the navigation column; `<x-list-detail>` is the second pane of a list-detail layout from `expanded:`, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
|
||||
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 3–5 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
|
||||
- Canonical layouts: feed (a grid of cards that gains columns per class), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
|
||||
- Bidirectionality: write logical properties (`ps-4`, `me-2`, `start-0`, `text-start`, `border-s`); leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
|
||||
|
||||
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
|
||||
|
||||
## Spacing
|
||||
|
||||
M3's spacing tokens are multiples of an 8px base on a 4px grid; Tailwind's spacing scale is that grid, so `p-4` is `space200`.
|
||||
|
||||
| Token | Value | Utility |
|
||||
| --- | --- | --- |
|
||||
| space25 | 2px | `p-0.5`, `gap-0.5` |
|
||||
| space50 | 4px | `p-1` |
|
||||
| space75 | 6px | `p-1.5` |
|
||||
| space100 | 8px (the base) | `p-2`, `gap-2` |
|
||||
| space125 | 10px | `p-2.5` |
|
||||
| space200 | 16px | `p-4` — a component's padding, compact margins |
|
||||
| space300 | 24px | `p-6` — a dialog's padding, margins from `medium:` |
|
||||
| space400 | 32px | `p-8` |
|
||||
| space500 | 40px | `p-10` |
|
||||
| space600 | 48px | `p-12` — a target |
|
||||
| space700 | 56px | `p-14` |
|
||||
| space800 | 64px | `p-16` |
|
||||
| space900 | 72px | `p-18` |
|
||||
|
||||
- Padding and gaps live on the parent (`p-4 gap-2` on the container), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
|
||||
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
|
||||
- Name a gap by what it separates when a component has several (icon–label 8px, label–supporting text 4px).
|
||||
|
||||
Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.io/styles/spacing/tokens · https://m3.material.io/styles/spacing/applying-spacing
|
||||
|
||||
## Icons
|
||||
|
||||
`<x-icon name="…">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or `optical="20"`.
|
||||
|
||||
| Axis | Values | In this library |
|
||||
| --- | --- | --- |
|
||||
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
|
||||
| Weight | 100–700; never below 200 at 24px | 400 for every icon; one weight per group |
|
||||
| Grade | −25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
|
||||
| Optical size | 20 dense, 24 standard, 40–48 with display type | `optical="20"` when drawn at 20px or less (small buttons, chips, dense lists), 24 otherwise |
|
||||
|
||||
- An icon beside text takes the text's size and colour (`size-5` beside `type-label-lg`, `size-6` beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
|
||||
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
|
||||
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
|
||||
|
||||
Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/styles/icons/designing-icons · https://m3.material.io/styles/icons/applying-icons
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
|
||||
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
|
||||
- Headings: one H1 for the page, then H2–H6 in order without skipping; the level is the document's structure, `type-*` is the appearance, and they need not match.
|
||||
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`.
|
||||
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
|
||||
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
|
||||
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
|
||||
|
||||
Sources: https://m3.material.io/foundations/overview/principles · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/designing/flow · https://m3.material.io/foundations/designing/elements · https://m3.material.io/foundations/overview/assistive-technology
|
||||
|
||||
## Don'ts
|
||||
|
||||
What Google's pages say not to do, and this library follows:
|
||||
|
||||
| Don't | Because | Instead |
|
||||
| --- | --- | --- |
|
||||
| Put an icon in a snackbar | a snackbar is a short message with at most one action | `<x-toast>` types choose the announcement, not a picture |
|
||||
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
|
||||
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
|
||||
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
|
||||
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `border-divider` (`outline-variant`) |
|
||||
| Use a hex, `white`, `black` or an opacity for ink | it ignores theme, contrast level and profile | a role |
|
||||
| Use Tailwind's breakpoints or scales | they are not M3's values | `medium:` … and `type-*`, `rounded-corner-*`, `shadow-elevation-*`, `ease-*` |
|
||||
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
|
||||
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
|
||||
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
|
||||
|
||||
## Attribution
|
||||
|
||||
The rules, tables and wording here are Google's, condensed from the Material Design 3 documentation at https://m3.material.io (Foundations, Styles and Components), which Google publishes under the Creative Commons Attribution 4.0 License except as otherwise noted; the numeric token values are from the Android Open Source Project's Material 3 token files in androidx Compose (Apache License 2.0). Copyright Google LLC; Copyright The Android Open Source Project. The library's `NOTICE` records the same. Dates and page names are those of the site as read on 2026-09-13; the full extracted references, with every source page, are kept in the package repository under `docs/reference/m3/`.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: tailwindcss-development
|
||||
description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
|
||||
license: MIT
|
||||
metadata:
|
||||
author: laravel
|
||||
---
|
||||
|
||||
# Tailwind CSS Development
|
||||
|
||||
## Documentation
|
||||
|
||||
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
|
||||
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
|
||||
|
||||
## Tailwind CSS v4 Specifics
|
||||
|
||||
- Always use Tailwind CSS v4 and avoid deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
|
||||
### CSS-First Configuration
|
||||
|
||||
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
|
||||
|
||||
<!-- CSS-First Config -->
|
||||
```css
|
||||
@theme {
|
||||
--color-brand: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
### Import Syntax
|
||||
|
||||
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
|
||||
|
||||
<!-- v4 Import Syntax -->
|
||||
```diff
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
```
|
||||
|
||||
### Replaced Utilities
|
||||
|
||||
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------|-------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
## Spacing
|
||||
|
||||
Use `gap` utilities instead of margins for spacing between siblings:
|
||||
|
||||
<!-- Gap Utilities -->
|
||||
```html
|
||||
<div class="flex gap-8">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
|
||||
|
||||
<!-- Dark Mode -->
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
|
||||
Content adapts to color scheme
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
|
||||
- Using `@tailwind` directives instead of `@import "tailwindcss"`
|
||||
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
|
||||
- Using margins for spacing between siblings instead of gap utilities
|
||||
- Forgetting to add dark mode variants when the project uses dark mode
|
||||
Reference in New Issue
Block a user