# ExpressiveCSS - Complete Documentation for AI/LLM Systems

ExpressiveCSS is a Material Design 3 front-end framework built with Sass and TypeScript. It provides design tokens, light and dark themes, a responsive grid, utilities, styled controls, and interactive browser components.

This file consolidates the ExpressiveCSS framework documentation for code-generation systems. It describes version `0.16.0`, which is under active development.

- Package: `@expressivecss/expressive`
- Runtime target: modern browsers; the last five Chrome and Firefox versions
- JavaScript formats: ES module, CommonJS, and browser IIFE
- Styles: compiled CSS, minified CSS, and Sass sources
- Design system: [Material Design 3](https://m3.material.io/)

This file is the markup and JavaScript API contract. For **when** to use a component, its anatomy, placement, adaptive behavior, and common mistakes, read [`m3-guidelines.md`](m3-guidelines.md) first.

---

# Documentation contents

### Design guidelines

- Material 3 component guidelines (`m3-guidelines.md`) — usage, anatomy, placement, adaptive design, behaviors. Use this file (`llm.md`) for classes, markup, and APIs.

### Getting started

- Getting started

### CSS foundations

- Themes
- Color
- Grid
- Helpers
- Media Styles
- Shadow
- Table
- Transitions
- Typography
- State layers

### CSS components

- Badges
- Avatar
- Banner migration
- Breadcrumbs
- Buttons
- Cards
- Aura
- Carousel
- Image compare
- Drag handle
- Lists
- Accordion
- Data table
- Floating Action Button
- Footer
- Icon buttons
- Icons
- Navbar
- Navigation bar
- Navigation rail
- Pagination
- Panes
- Preloader
- Loading indicator
- Skeleton
- Empty state
- Alert
- Popover
- Mega menu
- Stepper
- Timeline
- Calendar
- Stat
- Description list
- Tree
- Search
- Button groups
- Split button

### JavaScript components

- Auto Init
- Navigation rail
- Menu
- Menubar
- Tour
- Media
- Dialogs
- Bottom sheet
- Side sheet
- Scrollspy
- Tabs
- Snackbar
- Tooltips
- Toolbars
- Command palette
- Line chart
- Column chart
- Bar chart
- Pie chart
- Heatmap chart
- Radar chart
- Radial chart
- Mixed chart
- Scatter chart
- Sankey chart
- Funnel chart

### Forms

- Date Picker
- Time Picker
- Text Inputs
- Drop zone
- Fieldsets
- Rating
- Switches
- Select
- Sliders
- Radio Buttons
- Chips
- Checkboxes
- Autocomplete

---

# Core guidance for generated code

## Framework principles

ExpressiveCSS combines semantic HTML with component classes and single-purpose utilities. Choose components using [`m3-guidelines.md`](m3-guidelines.md) (window size class, one navigation pattern, one high-emphasis action, dialog vs snackbar vs sheet). Use the documented HTML structure for a component, then add helpers for spacing, alignment, visibility, color, and elevation instead of duplicating those rules in custom CSS.

- Start with the native HTML element when the documentation does so, especially for buttons, links, inputs, selects, tables, and dialogs.
- Preserve required child classes and ID relationships. JavaScript components query those selectors directly.
- Use the 12-column responsive grid classes instead of inventing a second layout convention.
- Use Material Design color roles such as `primary`, `surface`, and `error`; do not hard-code colors when a documented role or token applies.
- Add accessible labels, button types, alt text, and keyboard-compatible elements. A visual icon is not an accessible name.
- Initialize interactive components after their markup exists.
- Do not initialize an element twice. Add `no-autoinit` when manually initializing an element that `AutoInit()` would otherwise claim.
- Do not assume importing JavaScript calls `AutoInit()`. Import-time behaviors are installed, but registry components require an explicit `AutoInit()` or component `init()` call.

## Installation and builds

Install the published package:

```sh
npm install @expressivecss/expressive
```

Or build the repository sources:

```sh
npm install
npm run build
```

The build writes expanded and minified CSS, ESM, CommonJS, IIFE browser bundles, source maps, and TypeScript declarations under `dist/`.

## Package entry points

| Import | Purpose |
| --- | --- |
| `@expressivecss/expressive` | JavaScript module and TypeScript declarations |
| `@expressivecss/expressive/modular` | ESM exports and types without import-time initialization |
| `@expressivecss/expressive/css` | Expanded compiled stylesheet |
| `@expressivecss/expressive/css/min` | Minified compiled stylesheet |
| `@expressivecss/expressive/fonts/*` | Self-hosted woff2 files |
| `@expressivecss/expressive/scss` | Sass entry point |
| `@expressivecss/expressive/scss/custom` | Configurable component and utility selection |
| `@expressivecss/expressive/scss/*` | Individual Sass source paths |

## Minimal browser setup

```html
<!doctype html>
<html lang="en" theme="light">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="dist/css/expressive.min.css">
  </head>
  <body>
    <main class="container">
      <!-- ExpressiveCSS markup -->
    </main>
    <script src="dist/js/expressive.min.js"></script>
    <script>
      document.addEventListener('DOMContentLoaded', () => {
        Expressive.AutoInit();
      });
    </script>
  </body>
</html>
```

The IIFE bundle exposes the global `Expressive` object. The compiled stylesheet ships `@font-face` rules for variable Material Symbols (outlined, rounded, and sharp, with the opsz / wght / FILL / GRAD axes), Roboto and the Noto Sans fallback at 400 / 500 / 700 in Latin, Cyrillic, Greek, Vietnamese and (Noto Sans) Devanagari subsets. Keep `dist/fonts/` next to `dist/css/` so the relative font URLs resolve. Override the brand/plain tokens when the page uses different typefaces. Do not load the older Material Icons stylesheet; use `.material-symbols`.

## ES module setup

```js
import '@expressivecss/expressive/css';
import { AutoInit, Tooltip } from '@expressivecss/expressive';

AutoInit();

const element = document.querySelector('.custom-tooltip');
if (element) Tooltip.init(element, { position: 'top' });
```

## Sass setup

For smaller consumer bundles, import selected components from
`@expressivecss/expressive/modular` and call their `.init()` methods after markup
exists. Call the required document behaviors explicitly, such as `Forms.Init()`
for input enhancements and `Dialogs.Init()` for light dismissal. Do not repeat
these calls alongside the root entry. `AutoInit` still includes the full registry.
Both ESM entries share constructors; hosting them directly requires their chunks.

Use `@use "pkg:@expressivecss/expressive/scss/custom" with ($components:
("icons-material-design", "tabs"), $utilities: ());` with Sass's
Node package importer. Tokens and base remain included. Lists preserve the given
order; `null` includes a whole group and `()` omits it. For FormSelect and
Datepicker use `("icons-material-design", "buttons", "menu", "forms", "datepicker")`.
Add `"docked-display"` before `"forms"` for the optional docked picker, or `"dialog"` and `"modal-display"` for the modal one.

`$expressive-include-fonts: false` lets consumers supply subset font faces, but
also removes the text font declarations. Restore Roboto and Noto Sans 400/500/700
or explicitly choose alternative typefaces. Retain icon ligatures and variable
axes, and include dynamic icons: Chips and dismissible Snackbar use `close`,
and expandable navigation rail summaries use `expand_more`. The README includes font
declarations and the full initialization table. Full fonts remain available.

Frosted glass is off by default. `--expressive-glass: true` on `:root` or an
ancestor frosts menus, tooltips, popovers, dialogs, the command palette, sheets,
the snackbar, navigation bar and rail, toolbars, and the search view; it has no effect on the surface itself.
`--expressive-glass-blur` (16px) and `--expressive-glass-opacity` (72%) tune it, and
`false` turns it off in a region. Sass `$expressive-glass`, `-blur`, and
`-opacity` set the defaults. Reduced-transparency and more-contrast preferences
restore solid surfaces.

```scss
@use "@expressivecss/expressive/src/sass/expressive";
```

The Sass entry point emits four cascade layers in this order: `tokens`, `base`, `components`, and `utilities`. Utilities therefore override component-layer declarations without selector-specificity escalation. Unlayered application CSS overrides normal declarations in all framework layers.

## Themes and color roles

Set `theme="light"` or `theme="dark"` on the root element:

```html
<html lang="en" theme="dark">
```

Switch at runtime by changing the attribute:

```js
document.documentElement.setAttribute('theme', 'dark');
```

Add the `vibrant` attribute to an element to resolve the surface roles in its subtree to the tertiary container instead of a neutral surface. It repaints nothing itself — it redeclares tokens, and the components under it that already read those roles follow.

The live Material tokens use the `--md-sys-color-<role>` naming scheme. Override `--md-source` to generate the primary, secondary, tertiary, neutral, and neutral-variant ramps at runtime. Use the live role token in component CSS rather than a `-light` or `-dark` source token.

```css
:root {
  --md-source: #6750a4;
}

.custom-panel {
  color: var(--md-sys-color-on-surface);
  background: var(--md-sys-color-surface);
}
```

## Responsive model

ExpressiveCSS uses the exact M3 window size classes: Compact `< 600px`, Medium `600–839px`, Expanded `840–1199px`, Large `1200–1599px`, and Extra-large `>= 1600px`. The 12-column grid maps those classes to `.s`, `.m`, `.l`, `.xl`, and `.xxl` prefixes respectively.

## JavaScript initialization

Initialize all registered components in a context:

```js
Expressive.AutoInit();

Expressive.AutoInit(document.querySelector('#app'), {
  Tooltip: { position: 'top' }
});
```

Opt an element out when it needs manual options:

```html
<a class="button tooltipped no-autoinit" data-tooltip="Hi">Hover</a>
```

Or give the element its options in markup. `AutoInit()` reads a `data-<name>-options` attribute holding a JSON object, where `<name>` is the component name in kebab case (`Menu` reads `data-menu-options`, `FormSelect` reads `data-form-select-options`, and `.date-picker` reads `data-datepicker-options` because the name comes from the component, not the class) and the keys are the option names used in script:

```html
<a class="button menu-trigger" data-target="actions" data-menu-options='{"closeOnClick": false}'>Actions</a>
```

- Options passed to `AutoInit(context, options)` win for the keys they set; a nested object such as `i18n` counts as one key. Tooltip reads its own `data-tooltip`, `data-tooltip-id` and `data-position` again each time it opens, so those win over both. Set those, and Menu's `contextMenu` (`data-context-menu`), in one place.
- Markup can set only options whose default is a string, number, boolean, array or plain object, with a value of the same kind. Nested keys are checked against the default object, and one rejected nested key leaves the whole object at its default. An object whose default is empty, such as FormSelect's `menuOptions`, accepts no keys from markup. Items of a non-empty default array need the kind of its first item. Callbacks, elements, options with a `null` default, Autocomplete's `allowUnsafeHTML` and the `data` lists of Chips and Autocomplete stay script-only.
- Invalid JSON, unknown keys and wrong kinds are skipped with a console warning, and those options keep their defaults. A value that passes the checks but still makes a component throw stops `AutoInit()`, as it would from script.
- Direct `init()` calls do not read the attribute. Menus inside a `.menubar` take Menubar's `menuOptions` instead.

`AutoInit()` scans once. Pass `{ watch: true }` as a third argument to keep watching the context: components start in markup added later and are destroyed when their markup is removed. The call returns a function that stops watching; without `watch` it does nothing.

```js
const stop = Expressive.AutoInit(document.body, {}, { watch: true });
document.querySelector('main').insertAdjacentHTML('beforeend', html); // started in a later microtask
function unmount() {
  stop(); // stops watching; started components keep running
}
```

- Added markup starts like the first scan, with the same options, `data-<name>-options` attributes and `no-autoinit` opt-out. The added element itself is checked as well as its descendants.
- An element that already has an instance keeps it, so starting a component yourself before adding its element works, and components that build their own markup (the Datepicker's month and year selects) keep their instances.
- Removal destroys only instances `AutoInit()` would have started; `no-autoinit` elements are left to your code. An element moved elsewhere in the page keeps its instance. Class and attribute changes on elements already in the page are ignored.
- `stop()` first handles changes not yet reported, so markup added just before the call still starts.
- Markup inserted at runtime becomes live components. Sanitize user content first, and drop component classes and `data-<name>-options` attributes you don't want started.
- Shadow roots are not entered. Pass a `ShadowRoot` as the context to watch inside one. Call it once per root. A component that throws while starting stops the rest of that batch; later changes are still handled.

`AutoInit()` recognizes these public components and selectors:

| Component | Selector |
| --- | --- |
| `AppBar` | `header.medium, header.large, header:has(> :is(nav:not(.tabs, .navigation-bar, .navigation-rail), .bar) > .search-bar)` |
| `Autocomplete` | `.autocomplete` |
| `ButtonGroup` | `.button-group` |
| `Cards` | `article:has(> aside[id]:not([id=""])):not(:has(> aside ~ aside)):has(> button.card-reveal-trigger[type="button"], > :not(aside) button.card-reveal-trigger[type="button"])` |
| `ExpandingCard` | `article.expanding-card:has(> dialog.expanding-card-dialog)` |
| `Carousel` | `.carousel` |
| `Chips` | `.chips` |
| `Confetti` | `.confetti` |
| `Particles` | `canvas.particles` |
| `Keypad` | `.keypad, .keyboard` |
| `ColorPicker` | `.color-picker` |
| `BarChart` | `.bar-chart` |
| `PieChart` | `.pie-chart` |
| `HeatmapChart` | `.heatmap-chart` |
| `RadarChart` | `.radar-chart` |
| `RadialChart` | `.radial-chart` |
| `MixedChart` | `.mixed-chart` |
| `ScatterChart` | `.scatter-chart` |
| `SankeyChart` | `.sankey-chart` |
| `FunnelChart` | `.funnel-chart` |
| `TreemapChart` | `.treemap-chart` |
| `ColumnChart` | `.column-chart` |
| `CommandPalette` | `dialog.command-palette` |
| `Questionnaire` | `form.questionnaire` |
| `Datepicker` | `.date-picker` |
| `LineChart` | `.line-chart` |
| `Menu` | `.menu-trigger` |
| `MessageScroller` | `.message-scroller` |
| `PullToRefresh` | `.pull-to-refresh` |
| `LoadingOverlay` | `.loading-overlay` |
| `SwipeActions` | `.list.swipeable` |
| `InputMask` | `input[data-mask]` of type text, tel, search, url or password |
| `ShowMore` | `.show-more` |
| `CurrencyInput` | `input[data-currency]` of type text |
| `Lightbox` | `.lightboxed` |
| `NavigationRail` | `.navigation-rail` |
| `ScrollSpy` | `.scrollspy` |
| `BackToTop` | `.back-to-top` |
| `FormSelect` | `select` without `data-tree` |
| `TreeSelect` | `select[data-tree]` |
| `Cascader` | `.cascader` |
| `Tabs` | `.tabs` |
| `Timepicker` | `.time-picker` |
| `Tooltip` | `.tooltipped` |
| `HoverCard` | `.hover-card-trigger` |
| `FloatingActionButton` | `.fab-menu` |
| `Menubar` | `.menubar` |

`Snackbar`, `CharacterCounter`, and `Slider` are intentionally not in the registry. Construct or initialize them through their documented APIs. Importing the bundle also installs document-level keyboard/focus handlers and initializes the shared Forms, Chips, Slider, Cards, ExpandingCard, Dialogs, BottomSheets, SideSheets, Copy, Theme, PaneResize, ImageCompare, Tour, Sortable, and TransferList behaviors.

## Component lifecycle

Most per-element JavaScript components share this pattern:

```js
const element = document.querySelector('.component');
const instance = Expressive.ComponentName.init(element, options);
const current = Expressive.ComponentName.getInstance(element);
current?.destroy();
```

`init()` also accepts a NodeList or HTMLCollection and returns an array of component instances. Empty collections return an empty array. Re-initializing an element destroys its previous instance first. Component-specific sections below define additional methods, properties, callbacks, and option defaults.

Menu, Lightbox, NavigationRail, Cards and ExpandingCard fire DOM events from the element they were initialized on: `expressive:open` and `expressive:close` when the state changes, then `expressive:opened` and `expressive:closed` when the motion ends. Cards and ExpandingCard fire only `expressive:open` and `expressive:close`. Each event follows the matching `on…` callback, which still works. The events bubble and are composed, so a document listener sees components inside open shadow roots; check `event.target` because nested components bubble too. Closing a Menu or Lightbox from its `expressive:open` listener stops it opening.

```js
document.addEventListener('expressive:open', (event) => {
  if (event.target.matches('.menu-trigger')) analytics.track('menu-open');
});
```

Choices and changes carry what was chosen in `event.detail`:

| Event | Fired by | `detail` |
| --- | --- | --- |
| `expressive:change` | Tabs, Carousel | `{ panel }`, `{ item, dragged }` |
| `expressive:select` | Menu, Datepicker, Timepicker | `{ item }`, `{ date }`, `{ hours, minutes }` |
| `expressive:confirm`, `expressive:cancel` | Datepicker, Timepicker | none |
| `expressive:autocomplete` | Autocomplete | `{ values }` |
| `expressive:create` | Autocomplete, from its `Add` row | `{ text }` |
| `expressive:chip-add`, `chip-select`, `chip-delete` | Chips | `{ chip }` |
| `expressive:change` | ButtonGroup, Menu checkbox and radio items, ScrollSpy (from the section), Tour | `{ button, pressed }`, `{ item, checked }`, `{ link }`, `{ step, index }` |
| `expressive:open`, `expressive:close` | Tooltip, HoverCard, Snackbar, Tour, ShowMore | Tour's close: `{ completed }` |
| `expressive:action`, `expressive:closed` | Snackbar | none |
| `expressive:copy` | Copy buttons | `{ text, copied }` |
| `expressive:refresh` | Pull-to-refresh | `{ waitUntil }` |
| `expressive:swipetoggle` | Swipe actions | `{ row, open }` |
| `expressive:transfer` | Transfer list, from the container | `{ rows, from, to }` |
| `expressive:reject` | Opted-in drop zones, from the wrapper | `{ input, files, rejected, reason }`; reason is `type`, `size` or `count`; noncancelable |
| `expressive:change`, `expressive:sort`, `expressive:resize`, `expressive:filter`, `expressive:page` | Data tables, from the wrapper | `{ selected }`, `{ header, column, direction }`, `{ header, column, width }`, `{ query }`, `{ page, pageSize }`; `sort`, `filter` and `page` are cancelable |

An Autocomplete or Select input hosts a Menu, so it also fires that Menu's `expressive:open`, `expressive:close` and `expressive:select`. A callback that changes the choice again, such as an `onShow` that selects another tab, leaves only the event for the final choice.

Tooltip fires `expressive:open` and `expressive:close` when it actually shows and hides, after its delays. A snackbar fires its events from the snackbar itself; `expressive:closed` comes just before it leaves the page. Tour's `expressive:close` says whether the Done button finished it.

## Public JavaScript surface

The main bundle exports:

- `AutoInit`, `Forms`, and `version`
- `Copy` and `Theme`, the copy button and theme switch behaviors
- `PaneResize`, `ImageCompare`, `Tour`, `Sortable` and `TransferList` behaviors, and the `Menubar` component
- `Dialogs`, `BottomSheets`, and `SideSheets`
- `Autocomplete`, `FloatingActionButton`, `Cards`, `Carousel`, and `CharacterCounter`
- `Chips`, `Datepicker`, `Menu`, and `Lightbox`
- `Slider` and `ScrollSpy`
- `FormSelect`, `NavigationRail`, and `Tabs`
- `Timepicker`, `Snackbar`, and `Tooltip`

---

# Getting started

## Getting started

Build Material Design 3 interfaces from semantic HTML, with light and dark themes and accessible components.

### Installation

Expressive comes in two different forms. You can select which version you want depending on your preference and expertise. The project is at version `0.16.0` and is still growing, so the usual path is to build from the repository.

#### Expressive

This is the standard version that comes with both the minified and unminified CSS and JavaScript files. After you build the project, copy the files from `dist/`. This option requires little to no setup. Use this if you are unfamiliar with Sass.

#### Sass

This version is the source SCSS in `src/sass`. By choosing this version you have more control over which layers to include. You will need a Sass compiler if you choose this option.

#### From source

Clone the repository and build the compiled CSS and JavaScript:

```sh
npm install
npm run build
```

The compiled assets are written to `dist/`:

```text
dist/
├── css/
│   ├── expressive.css
│   └── expressive.min.css
├── fonts/
├── js/
│   ├── expressive.cjs
│   ├── expressive.js
│   ├── expressive.min.js
│   ├── expressive.mjs
│   └── modular.mjs
└── types/
```

#### NPM

The package name is `@expressivecss/expressive`. This release contains source files as well as the compiled CSS and JavaScript files.

```sh
npm install @expressivecss/expressive
```

#### Yarn

Or you can add the package with yarn.

```sh
yarn add @expressivecss/expressive
```

### Setup

#### Project structure

After building, copy the compiled files into the directory where your website is located. Your directory will look something like this.

You'll notice that there are two sets of the files. The `min` means that the file is compressed to reduce load times. These minified files are usually used in production while it is better to use the unminified files during development.

```text
MyWebsite/
  |--css/
  |  |--expressive.css
  |  |--expressive.min.css
  |
  |--fonts/
  |  |--material-symbols-outlined.woff2
  |
  |--js/
  |  |--expressive.js
  |  |--expressive.min.js
  |
  |--index.html
```

#### HTML setup

Next you just have to make sure you link the files properly in your webpage. Generally it is wise to import JavaScript files at the end of the body to reduce page load time. Follow the example below on how to import Expressive into your webpage.

```html
<!DOCTYPE html>
<html lang="en" theme="light">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="css/expressive.min.css">
  </head>
  <body>
    <script src="js/expressive.min.js"></script>
    <script>
      document.addEventListener('DOMContentLoaded', () => {
        Expressive.AutoInit();
      });
    </script>
  </body>
</html>
```

#### Initialize JavaScript

The browser bundle exposes the framework as the global `Expressive` object. Importing the JavaScript installs shared document behaviors (forms, chips, cards, and a few others), but it does not call `AutoInit()` automatically. Call it after the page has loaded so components such as navigation rails, tooltips, and tabs start themselves.

`AutoInit()` scans `document.body` by default. Pass a container to limit the scan, or add the `no-autoinit` class to an element that should be initialized manually.

```js
Expressive.AutoInit();

Expressive.AutoInit(document.querySelector('#app'), {
  Tooltip: { position: 'top' }
});
```

#### ES modules

Import the framework or individual components from the module build:

```js
import { AutoInit, Tooltip } from './js/expressive.mjs';

AutoInit();

const element = document.querySelector('.custom-tooltip');
Tooltip.init(element, { position: 'top' });
```

#### Themes

Expressive uses the `theme` attribute on the root element. The default is light. Switch themes at runtime by updating the attribute.

```html
<html lang="en" theme="light">
```

```js
document.documentElement.setAttribute('theme', 'dark');
```

### Templates

Start from a documented layout instead of an empty page. These two pages show how Expressive structures content, and you can copy the markup they use.

#### Grid

#### Helpers

### Sass setup

This section is only relevant if you chose to use the Sass sources.

#### Compiling Sass

Instead of only a CSS folder, the repository contains many `.scss` files which contain the styles of individual layers and components. The browser cannot interpret Sass, so you must compile `src/sass/expressive.scss` into a regular CSS file. At this point you can link this newly outputted file in your HTML page.

From the repository, the npm script does that for you:

```sh
npm run build:css
```

In another Sass project, use the framework's Sass entry point:

```scss
@use "@expressivecss/expressive/src/sass/expressive";
```

When working directly in this repository, the entry point is `src/sass/expressive.scss`.

```text
MyWebsite/
|--css/
|  |--expressive.css <-- compiled from scss/expressive.scss
|
|--js/
|  |--expressive.js
|
|--scss/
|  |--expressive.scss
|  |--abstracts/
|  |--tokens/
|  |--utilities/
|  |--base/
|  |--components/
|
|--index.html
```

---

# CSS foundations

## Themes

Light and dark schemes, the theme attribute, and custom tokens.

Expressive maps Material Design 3 color tokens onto live `--md-sys-color-*` custom properties. Components, buttons, and the color utilities all read those live names, so flipping the scheme recolors the page without extra classes.

Three rules decide which pair is live, in this order:

1. Default: `:root` and `:host` set `color-scheme: light dark`, so `light-dark()` follows the OS.
2. `:root[theme='auto']` is the same follow-OS value, written explicitly.
3. `:root[theme='light']` and `:root[theme='dark']` lock the scheme and override the OS.

Each live token is `light-dark(var(--md-sys-color-<role>-light), var(--md-sys-color-<role>-dark))`, written once and resolved against the element's used `color-scheme` — there is no `prefers-color-scheme` media query behind the colors. Because it resolves at the point of use, setting `color-scheme` on any element re-themes that subtree, and native controls follow the same scheme. Each of the three rules above is paired with a `:host([theme='…'])` twin, so a shadow host can be pinned to a theme the same way `<html>` can. Every token-declaring rule in the sheet carries that pairing: the sheet is supported as a shadow root's *only* stylesheet, and `:root` matches the document element and nothing else.

This site starts as `<html lang="en" theme="auto">`. The theme control in the app bar is a menu: Light, Dark, and Auto. The choice is written to `localStorage` and restored before first paint so a reload does not flash the wrong scheme.

### Reading the theme

Put a short inline script in `<head>`, before the stylesheet paints, if you want to restore a saved choice and avoid a flash of the wrong scheme. The framework does not run this: the `Theme` behavior saves a choice when a control is clicked, but restoring it before first paint is the page's job.

Without a `theme` attribute the CSS already follows the OS through `color-scheme: light dark`. Setting the attribute to `light` or `dark` locks the scheme; `auto` goes back to following the OS. The snippet below reads a stored value first, then falls back to the OS preference, then writes the attribute so the override is explicit.

```html
<script>
  function getTheme() {
    const stored = localStorage.getItem('theme');
    if (stored === 'dark' || stored === 'light' || stored === 'auto') return stored;
    const prefersDark = window.matchMedia
      && window.matchMedia('(prefers-color-scheme: dark)').matches;
    return prefersDark ? 'dark' : 'light';
  }

  function setTheme(theme) {
    document.documentElement.setAttribute('theme', theme);
    localStorage.setItem('theme', theme);
  }

  setTheme(getTheme());
</script>
```

Keep this inline rather than in an external file so it runs before the first paint. Setting `theme="light"` in the HTML and never changing it locks the page to light until JavaScript flips the attribute.

### Changing the theme

Switch schemes by writing the attribute:

```js
document.documentElement.setAttribute('theme', 'dark');
```

Or let the `Theme` behavior do it. A click on any element with `data-theme-set="light"`, `"dark"` or `"auto"` writes the attribute and saves the choice to `localStorage` under `theme`, the key the snippet above reads. The root bundle starts it on load, and the modular entry with `Theme.Init()`. It marks every theme control as current or not: `aria-checked` on `menuitemradio` and `radio` roles, `aria-pressed` on anything else. Other values are ignored.

```html
<div class="button-group" role="group" aria-label="Theme">
  <button type="button" class="button" data-theme-set="light">Light</button>
  <button type="button" class="button" data-theme-set="dark">Dark</button>
  <button type="button" class="button" data-theme-set="auto">Auto</button>
</div>
```

From script, `Expressive.Theme.set('dark')` does the same as a click, `Expressive.Theme.get()` returns the current theme (`auto` without an attribute), and `Expressive.Theme.sync(root)` marks theme controls rendered after load. The behavior never restores a saved choice on load, because the bundle runs after first paint; keep the inline snippet for that.

### Creating a theme

Customize by overriding the Material Design 3 tokens. Do not set `--background-color`, `--primary-color`, or other pre-2.1 names — those are not wired in Expressive.

Three layers exist. `tokens/_reference.scss` generates the tonal ramps from `--md-source`, then resolves them into the pairs (`--md-sys-color-primary-light`, `--md-sys-color-primary-dark`, and the rest). `tokens/_theme.scss` collapses each pair into the live `--md-sys-color-*` name with `light-dark()`. Components only read the live names.

Override the `-light` and `-dark` pairs so both schemes stay consistent. Load your sheet **after** Expressive so equal-specificity `:root` rules win.

```css
:root {
  --md-sys-color-primary-light: #6750a4;
  --md-sys-color-on-primary-light: #ffffff;
  --md-sys-color-primary-container-light: #eaddff;
  --md-sys-color-on-primary-container-light: #21005d;

  --md-sys-color-primary-dark: #d0bcff;
  --md-sys-color-on-primary-dark: #381e72;
  --md-sys-color-primary-container-dark: #4f378b;
  --md-sys-color-on-primary-container-dark: #eaddff;
}
```

To restyle only one scheme, set the live names on the attribute selector. A bare `:root { --md-sys-color-primary: … }` loses to `:root[theme='dark']` because the attribute rule is more specific.

```css
:root[theme='dark'] {
  --md-sys-color-primary: #d0bcff;
  --md-sys-color-on-primary: #381e72;
  --md-sys-color-surface: #1c1b1f;
  --md-sys-color-on-surface: #e6e1e5;
}
```

The same custom properties inherit, so a wrapper can preview a palette without touching the rest of the page:

```html
<div style="
  --md-sys-color-primary: #6750a4;
  --md-sys-color-on-primary: #ffffff;
  --md-sys-color-primary-container: #eaddff;
  --md-sys-color-on-primary-container: #21005d;
">
  <a class="btn filled">Filled</a>
</div>
```

In Sass, consume the live token. The values are hex, so `rgba(var(--md-sys-color-primary), 0.06)` is invalid and the browser drops it. Mix with transparency instead:

```css
.my-surface {
  background-color: var(--md-sys-color-surface);
  color: var(--md-sys-color-on-surface);
}

.my-overlay {
  background-color: color-mix(in oklab, var(--md-sys-color-primary) 6%, transparent);
}
```

`--md-source` is the seed the ramps are generated from, and it is live: set it and every generated ramp — and every system color that resolves to one — recolors at runtime, with no rebuild. Expressive ships `#c6a0f6`, Mist Iris.

```css
:root {
  --md-source: #6750a4;   /* one line; the whole theme follows */
}
```

Five of the six ramps are derived this way with `oklch(from var(--md-source) …)`. The **error** ramp is deliberately not: Material fixes the error hue rather than deriving it, because "this went wrong" should not change color when you change your brand color.

Overriding an individual `-light`/`-dark` pair still works and still wins — that is the more surgical tool. `--md-source` is the blunt one.

Where the shipped seed `#c6a0f6` lands, for the roles you will most often replace. These are *resolved* values: the stylesheet ships the `oklch()` expression, and the browser computes the color — including gamut-mapping the entries whose target chroma sRGB cannot reach — so treat them as accurate to about a rounding step rather than as literals to paste back in.

| Token | Light | Dark |
| --- | --- | --- |
| `primary` | #6e4995 | #d8b2ff |
| `on-primary` | #ffffff | #3f1861 |
| `primary-container` | #f5ceff | #56307a |
| `on-primary-container` | #2a0048 | #f5ceff |
| `secondary` | #635a6b | #ccc1d5 |
| `secondary-container` | #e8ddf2 | #4b4253 |
| `tertiary` | #7a5059 | #e8b7c0 |
| `error` | #ba1a1a | #ffb4ab |
| `surface` | #faf8fb | #090809 |
| `on-surface` | #1c1b1d | #e3e1e4 |
| `outline` | #7a757c | #938e96 |

`error` is the one row that does not move when you change the seed — it is the ramp that is not generated.

The full list of role utilities lives on the Color page. Pair a fill with its `on-*` text class so contrast stays correct when the scheme flips.

### Vibrant emphasis

`vibrant` is Material 3 Expressive's emphasis axis, and it is a foundation rather than a component variant: the attribute remaps the surface family of `--md-sys-color-*` roles inside its subtree onto the tertiary container, and every component already reads those roles. Nothing is restyled until you write the attribute.

```html
<article vibrant>…</article>
```

The remap covers `surface`, all five `surface-container` rungs, `surface-dim`, `surface-bright`, and their `on-*` roles. Accent roles are untouched, so a filled button still stands out; outline roles are untouched too, because M3's outline is neutral-variant rather than an opacity of the text color, and deriving it would turn every checkbox, radio and switch border in the subtree translucent. It points at the live role names, so a runtime theme switch reaches it.

There is one ramp, tertiary — M3's for this axis. Pointing the surfaces at primary or secondary would erase every component whose own fill is that container: a tonal button, a FAB, `.toolbar.vibrant`. Set the surface tokens on your own wrapper if you want a different one.

The elevation ladder collapses — every `surface-container` rung resolves to the one container color, which is what a vibrant surface is in Material, and `on-surface-variant` collapses onto `on-surface` for the same reason — so put the attribute on the component you want emphasized, not on the page. Menus and toolbars also carry a `.vibrant` class; those are Material's own per-component token sets, and the attribute is the general axis. A menu inside a vibrant subtree adopts its vibrant mapping automatically — a selected item is filled with `tertiary-container`, the colour the attribute paints the menu with, so M3 moves selection to solid `tertiary`. `:host([vibrant])` is matched alongside `[vibrant]`, so a shadow-DOM host can carry it.

### More contrast

Material 3 has three contrast levels. Put `contrast="medium"` or `contrast="high"` on the root or on any subtree to use the medium or high scheme there. The accent roles, their containers, `on-surface-variant` and the outlines move to darker tones in the light scheme and lighter ones in the dark scheme, taken from the same ramps, so a runtime `--md-source` change still reaches them. Surfaces stay put. Medium brings accents to about 4.5:1 against the surface and high to 7:1, with outlines at 3:1 or more. When the user asks for more contrast (`prefers-contrast: more`), the page uses the high scheme unless a `contrast` attribute says otherwise; `contrast="standard"` opts a page or subtree out. The tones approximate Material's contrast curves rather than reproducing its color solver. A page that re-themes through the `-light` and `-dark` pairs should repeat those overrides inside its own `[contrast]` rule.

```html
<html contrast="high">
```

### Corner scale

Component shape tokens on the Material 3 corner scale read the `md.sys.shape.corner` tokens, so one override changes the corners of every component on that step. A component token such as `--md-comp-card-container-shape` still overrides one component. Off-scale corners stay literal.

| Token | Value |
| --- | --- |
| `--md-sys-shape-corner-none` | 0px |
| `--md-sys-shape-corner-extra-small` | 4px |
| `--md-sys-shape-corner-small` | 8px |
| `--md-sys-shape-corner-medium` | 12px |
| `--md-sys-shape-corner-large` | 16px |
| `--md-sys-shape-corner-large-increased` | 20px |
| `--md-sys-shape-corner-extra-large` | 28px |
| `--md-sys-shape-corner-extra-large-increased` | 32px |
| `--md-sys-shape-corner-extra-extra-large` | 48px |
| `--md-sys-shape-corner-full` | 9999px |

```css
:root { --md-sys-shape-corner-medium: 8px; } /* cards, menu items and other medium corners */
```

#### Cut corners

Material shapes come in a rounded and a cut family. `corners="cut"` on the root or on any subtree turns the corners of cards, dialogs, sheets, menus, fieldsets, buttons, chips, alerts, snackbars, tooltips, popovers and outlined fields into straight bevels of the same size; `corners="round"` puts a subtree back. The switch sets `--md-sys-shape-corner-family` to `bevel` or `round`. `.corner-cut` cuts a single element, together with its own radius or a `.corner-*` class. Round indicators such as radios, switches, avatars and `.circle` stay round, because a cut 50% radius is a diamond. Cut corners use CSS `corner-shape`. Browsers without it, including Firefox 157, keep rounded corners.

```html
<main corners="cut">…</main>
<div class="bordered corner-large corner-cut">One cut panel</div>
```

---

## Design tokens

Every token family in one place: color, type, state, motion, shape, elevation and stacking.

Every design decision the framework makes is a CSS custom property you can override at runtime, with no rebuild. The system tokens follow Material 3 names (`--md-sys-*` and `--md-ref-*`); components read them through their own `--md-comp-*` tokens, which change one component at a time.

| Family | Prefix | What it holds | Details |
| --- | --- | --- | --- |
| Color roles | `--md-sys-color-*` | `primary`, `on-surface`, `outline-variant`: 45 roles, each with a `-light` and `-dark` pair | [Color](https://www.expressivecss.com/color.html) |
| Tonal palettes | `--md-ref-palette-*` | Tones 0 to 100 of the primary, secondary, tertiary, neutral, neutral-variant and error ramps, generated from `--md-source` | [Themes](https://www.expressivecss.com/themes.html) |
| Contrast levels | `contrast="medium" | "high"` | An attribute, not a token: moves accents, outlines and `on-surface-variant` to higher-contrast tones | [Themes](https://www.expressivecss.com/themes.html#contrast) |
| Type scale | `--md-sys-typescale-*` | Font, size, weight, line height and tracking for the fifteen roles, and `--md-ref-typeface-*` | [Typography](https://www.expressivecss.com/typography.html) |
| State layers | `--md-sys-state-*` | Hover, focus, pressed and dragged opacities | [State layers](https://www.expressivecss.com/state-layers.html) |
| Motion | `--md-sys-motion-*` | Seven easing curves and sixteen durations, `short1` to `extra-long4` | [Transitions](https://www.expressivecss.com/css-transitions.html#motion-tokens) |
| Shape | `--md-sys-shape-corner-*` | The corner scale from `none` to `full` | [Shapes](https://www.expressivecss.com/shapes.html#corner-scale) |
| Elevation | `--md-sys-elevation-level*` | Shadows for levels 0 to 5 and 1-half | [Elevation](https://www.expressivecss.com/shadow.html) |
| Stacking | `--expressive-z-*` | The z-index order of fixed and floating surfaces | [Elevation](https://www.expressivecss.com/shadow.html#stacking) |

Set a system token on `:root` to change every component, or on any element to change only what is inside it. Each token is declared on `:root` and `:host`, so the same overrides work when the stylesheet is adopted by a shadow root.

```css
:root {
  --md-source: #006495;                   /* re-themes every color role */
  --md-sys-shape-corner-medium: 8px;      /* squarer cards and menus */
  --md-sys-motion-duration-short4: 150ms; /* faster 200ms transitions */
}
.dense-panel { --md-sys-elevation-level1: none; }
.my-card { --md-comp-card-container-shape: 0; }  /* one component */
```

---

## Color

One system: the Material Design 3 theme tokens.

Color in Expressive is the Material Design 3 Expressive roles — CSS custom properties on `:root` named `--md-sys-color-*`, exposed as utility classes like `.primary` and `.on-surface-text`. There are 26 standard roles in six groups (primary, secondary, tertiary, error, surface, outline), plus optional add-ons for fixed accents and the surface container scale.

The 2014 Material palette that shipped with earlier versions (`.red`, `.blue.lighten-2`, `colorFunc()`) has been removed. It generated 532 utility classes — 18% of the stylesheet — expressing a design opinion this framework does not hold, and it did not follow the page theme. Replace a palette class with the role it was standing in for: `.red` → `.error`, `.blue` → `.primary`, `.green` → `.tertiary`, and pair each fill with its `on-*` text class.

Token values follow the page theme. The docs site sets `<html theme="light">` or `theme="dark"`. Without that attribute, the tokens follow `prefers-color-scheme`.

A background class sets `background-color`. Append `-text` for `color`. Pair roles as the spec intends so contrast stays at least 3:1: primary for the FAB and filled buttons, secondary for filter chips and selected nav pills, tertiary for contrasting accents, error for error states, surface for page backgrounds, surface-container* for cards/sheets/dialogs/menus. `on-*` is text and icons on that fill. Container roles are fills, not text.

```html
<div class="primary on-primary-text">.primary</div>
<span class="on-surface-text">.on-surface-text</span>
```

Every role name is a background class. The same name plus `-text` is the foreground class (`.on-surface-text`). Prefer the `on-*` text class on its paired fill.

Standard pairs: `primary` / `on-primary`, `primary-container` / `on-primary-container`, and the same for secondary, tertiary, and error. Surface: `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest` … `surface-container-highest`, `on-surface`, `on-surface-variant`. Outline: `outline`, `outline-variant`. Inverse: `inverse-surface` / `inverse-on-surface`, `inverse-primary`. Overlay: `scrim`, `shadow`. Optional fixed accents (`primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`, and the secondary/tertiary copies) stay the same in light and dark. `background`, `on-background`, `surface-variant`, and `surface-tint` remain as aliases.

`scrim` is the opaque neutral the wash behind a modal surface is mixed from, not the wash itself. That is `--md-comp-scrim-color` — the role at 32%, defined once on `:root, :host` (so a sheet adopted into a shadow root gets one too) and consumed by dialogs, both sheets, and the modal navigation rail. Override it at the root to retheme all of them, or set it on one element to dim just that surface (`::backdrop` inherits from the element it belongs to). The mix resolves at the root, so overriding `--md-sys-color-scrim` on a subtree does not reach a scrim below it — override `--md-comp-scrim-color` there instead.

In Sass, consume the token directly. Do not write `rgba(var(--md-sys-color-primary), 0.06)` — the tokens hold hex colors, so that form is invalid. Mix with transparency instead:

```css
.my-tint {
  background-color: var(--md-sys-color-primary);
  color: var(--md-sys-color-on-primary);
}

.my-overlay {
  background-color: color-mix(in oklab, var(--md-sys-color-primary) 6%, transparent);
}
```

Mix `in oklab`, not `in srgb`. sRGB interpolation dips in lightness through the midtones, so the same percentage reads muddier on some hues than others; OKLab is perceptually uniform, so a 16% state layer looks like 16% everywhere.

### Every role

A role is a job, not a color: `error` means "this went wrong", and what that looks like is the theme's business.

### Sass

Do not `@extend` these classes across files — that is how the old stylesheet lost control of cascade order. In a component partial, read the token directly.

```css
.my-panel {
  background-color: var(--md-sys-color-surface-container);
  color: var(--md-sys-color-on-surface);
}
```

There is no Sass color function any more. `colorFunc()` and the `$colors` map were removed with the palette — a Sass function resolves at build time, which cannot follow a theme the user switches at runtime. The custom property can.

---

## Grid

Use Expressive's CSS Grid system to format a page in an ordered, comfortable way.

We are using a standard 12 column fluid responsive grid system. The grid helps you layout your page in an ordered, easy fashion.

### Container

The container class is not strictly part of the grid but is important in laying out content. It allows you to center your page content. The `container` class is set to ~70% of the window width. It helps you center and contain your page content. We use the container to contain our body content.

#### Demo

Try the button below to see what the page looks like without containers.

To add a container just put your content inside a `<div>` tag with a `container` class. Here's an example of how your page might be set up.

```html
<body>
  <div class="container">
    <!-- Page Content goes here -->
  </div>
</body>
```

### Introduction

Take a look at this section to quickly understand how the grid works!

#### 12 Columns

Our standard grid has 12 columns. No matter the size of the browser, each of these columns will always have an equal width.

To get a feel of how the grid is used in HTML, take a look at the code below which will produce a similar result to the one above.

```html
<div class="row">
  <div class="s1">1</div>
  <div class="s1">2</div>
  <div class="s1">3</div>
  <div class="s1">4</div>
  <div class="s1">5</div>
  <div class="s1">6</div>
  <div class="s1">7</div>
  <div class="s1">8</div>
  <div class="s1">9</div>
  <div class="s1">10</div>
  <div class="s1">11</div>
  <div class="s1">12</div>
</div>
```

`s1` means one column at the Compact/default size and up.

#### Columns live inside Rows

Remember when you are creating your layout that all columns must be contained inside a row. Size each inner div with a screen-prefix class such as `s12` or `m6`.

```html
<div class="row">
  <div class="s12">This div is 12-columns wide on all screen sizes</div>
  <div class="s6">6-columns (one-half)</div>
  <div class="s6">6-columns (one-half)</div>
</div>
```

### Offsets

To offset, simply add `offset-s2` to the class where the prefix identifies the window size (`s` = Compact, `m` = Medium, `l` = Expanded, `xl` = Large, `xxl` = Extra-large) and the number is how many columns to offset.

Attention! Offsets are calculated absolutely starting from the left. If you need relative offsets, add empty columns to the row.

```html
<div class="row">
  <div class="s12">This div is 12-columns wide on all screen sizes</div>
  <div class="s6 offset-s6">6-columns (offset-by-6)</div>
</div>
<div class="row">
  <div class="l6">l6 column</div>
  <div class="l4 offset-l8">l4 column (offset-l8)</div>
</div>
<div class="row">
  <div class="l2 offset-l3">offset-l3</div>
  <div class="l2 offset-l6">offset-l6</div>
  <div class="l2 offset-l9">offset-l9</div>
</div>
<div class="row">
  <div class="l2">col</div>
  <div class="l2"></div>
  <div class="l2">relative offset 2</div>
</div>
```

### Gaps

There is a default gap between the columns (g-3). You can easily change the gap with the gap classes. Simply add `g-0` to `g-5` to the element with the `row` class.

```html
<div class="row g-4">
  <div class="s4">1</div>
  <div class="s4">2</div>
  <div class="s4">3</div>
</div>
```

### Push and pull

Deprecated. CSS Grid is used now — use offsets, or empty columns, to change placement.

Older versions reordered columns with `push-s2` or `pull-s2`, where `s` is the screen class-prefix and the number is how many columns to push or pull by. Those classes are no longer part of the grid.

### Creating layouts

Here we will show you how to create some commonly used layouts with our grid system. Hopefully these will get you more comfortable with laying out elements. To keep these demos simple, the ones here will not be responsive.

#### Section

The section class is used for simple top and bottom padding. Just add the `section` class to your divs containing large blocks of content.

#### Divider

Dividers are 1 pixel lines that help break up your content. Just add the `divider` class to a div in between your content.

#### Example Sections and Dividers

```html
<div class="divider"></div>
<div class="section">
  <h5>Section 1</h5>
  <p>Stuff</p>
</div>
<div class="divider"></div>
<div class="section">
  <h5>Section 2</h5>
  <p>Stuff</p>
</div>
<div class="divider"></div>
<div class="section">
  <h5>Section 3</h5>
  <p>Stuff</p>
</div>
```

#### Example Promotion Table

If we want 3 divs that are equal size, we define the divs with a width of 4-columns, as 4+4+4 nicely adds up to 12. Inside those divs, we can put our content.

```html
<div class="row">
  <div class="s4">
    <!-- Promo Content 1 goes here -->
  </div>
  <div class="s4">
    <!-- Promo Content 2 goes here -->
  </div>
  <div class="s4">
    <!-- Promo Content 3 goes here -->
  </div>
</div>
```

#### Example Side Navigation Layout

You can see how easy it is to create layouts using the grid system. Just remember to make sure your column numbers add up to 12 for an even layout.

```html
<!-- Navbar goes here -->
<!-- Page Layout here -->
<div class="row">
  <div class="s3">
    <!-- Grey navigation panel -->
  </div>
  <div class="s9">
    <!-- Teal page content -->
  </div>
</div>
```

### Creating responsive layouts

Above we showed you how to layout elements using our grid system. Now we'll show you how to design your layouts so that they look great on all screen sizes.

#### Screen Sizes

|  | Compact `< 600px` | Medium `600–839px` | Expanded `840–1199px` | Large `1200–1599px` | Extra-large `>= 1600px` |
| --- | --- | --- | --- | --- | --- |
| **Class Prefix** | `.s` | `.m` | `.l` | `.xl` | `.xxl` |
| **Container Width** | 90% | 85% | 70% | 70% | 75% |
| **Number of Columns** | 12 | 12 | 12 | 12 | 12 |

#### Adding Responsiveness

In the previous examples, we only defined the Compact/default size using `s12`. This is fine if we want a fixed layout since the rules propagate upwards. By just saying `s12`, we are essentially saying `s12 m12 l12 xl12 xxl12`. Define later prefixes when the layout should change at wider window size classes.

```html
<div class="row">
  <div class="s12">I am always full-width (s12)</div>
  <div class="s12 m6">I am full-width on mobile (s12 m6)</div>
</div>
```

#### Responsive Side Navigation Layout

In this example below, we take the same layout from above, but we make it responsive by defining how many columns the div should take up on each screen size. Try resizing your browser and watch the layout change below.

```html
<!-- Navbar goes here -->
<!-- Page Layout here -->
<div class="row">
  <div class="s12 m4 l3">
    <!-- Grey navigation panel
          This content will be:
      3-columns-wide on Expanded and wider screens,
      4-columns-wide on Medium screens,
      12-columns-wide on Compact screens -->
  </div>
  <div class="s12 m8 l9">
    <!-- Teal page content
          This content will be:
      9-columns-wide on Expanded and wider screens,
      8-columns-wide on Medium screens,
      12-columns-wide on Compact screens -->
  </div>
</div>
```

#### More Responsive Grid Examples

```html
<div class="row">
  <div class="s12">s12</div>
  <div class="s12 m4 l2">s12 m4 l2</div>
  <div class="s12 m4 l8">s12 m4 l8</div>
  <div class="s12 m4 l2">s12 m4 l2</div>
  <div class="s12 m6 l3">s12 m6 l3</div>
  <div class="s12 m6 l3">s12 m6 l3</div>
  <div class="s12 m6 l3">s12 m6 l3</div>
  <div class="s12 m6 l3">s12 m6 l3</div>
</div>
```

---

## Helpers

An overview of the helper classes for alignment, visibility, spacing, and common CSS properties.

### Alignment

Easy-to-use classes to help you align your content.

#### Vertical Align

You can easily vertically center things by adding the class `valign-wrapper` to the container holding the items you want to vertically align.

```html
<div class="valign-wrapper">
  <h5>This should be vertically aligned</h5>
</div>
```

#### Text Align

These classes are for horizontally aligning content: `.left-align`, `.right-align` and `.center-align`. Use `.start-align` and `.end-align` to follow the text direction with `text-align: start` and `text-align: end`. The physical left and right classes keep their existing behavior.

```html
<div>
  <h5 class="left-align">This should be left aligned</h5>
</div>
<div>
  <h5 class="right-align">This should be right aligned</h5>
</div>
<div>
  <h5 class="center-align">This should be center aligned</h5>
</div>
```

To center text on mobile only, add `center-on-small-only`.

### Display and position

| Class | Declaration |
| --- | --- |
| `.block`, `.inline-block` | `display: block`, `display: inline-block` |
| `.grid`, `.inline-grid` | `display: grid`, `display: inline-grid` |
| `.grid-auto-fill` | A grid with as many columns as fit, each at least `--grid-column-min` wide (240px by default) |
| `.contents` | `display: contents`; the element's box goes away and its children join the parent's layout |
| `.relative` | `position: relative` |
| `.sticky-top` | `position: sticky; top: 0` |
| `.position-absolute`, `.position-fixed`, `.position-static` | `position: absolute`, `position: fixed`, `position: static` |
| `.inset-0` | `inset: 0`; the element covers its containing block |
| `.top-0`, `.bottom-0` | `top: 0`, `bottom: 0` |
| `.start-0`, `.end-0` | `inset-inline-start: 0`, `inset-inline-end: 0`; they swap sides in RTL |
| `.query-container` | `container-type: inline-size`. Horizontal cards and description lists inside it stack when it is narrower than 600px, on any window. |

`.inline` is the row of checkboxes or radios, so there is no `display: inline` class. A `.sticky-top` element sticks inside its nearest scrolling ancestor; an ancestor with `overflow: hidden` stops it. Give the parent of an absolute element `.relative`. There is no bare `.fixed` class, because the navigation bar, toolbar and table of contents use `.fixed` as a modifier. `.position-fixed` sets no `z-index`; to lift a fixed element above framework surfaces, give it a `--expressive-z-*` value. An ancestor with a `transform`, `filter` or `contain` becomes the containing block of a fixed element. No position class has a window-size prefix, and `.position-static` undoes `.relative` and `.sticky-top`. Browsers have dropped the semantics of elements with `display: contents`, so put `.contents` on plain wrappers, not on buttons, lists, tables or headings. A `.query-container` can no longer size itself from its content, so put it on a wrapper that fills its parent, such as a sidebar column, not on an inline or shrink-to-fit box.

`.grid-auto-fill` is `repeat(auto-fill, minmax(min(100%, var(--grid-column-min, 15rem)), 1fr))`. It adds a column whenever another one of the minimum width fits, and the columns share what is left. Set `--grid-column-min` on the grid or on an ancestor. The last row keeps the same column width when it has fewer items. A container narrower than the minimum gets one column as wide as itself. The class sets no gap, so pair it with `.gap-*`, and it has no window-size prefixes, because the column count already follows the container's width.

```html
<div class="grid-auto-fill gap-3" style="--grid-column-min: 10rem">
  <article class="outlined">…</article>
  <article class="outlined">…</article>
  <article class="outlined">…</article>
</div>
```

### Flex

One class per declaration, applied to the container. Row direction and start alignment are the flex defaults, but `.flex-row` and `.flex-nowrap` exist so a window-size prefix can switch back to them.

| Class | Declaration |
| --- | --- |
| `.flex` | `display: flex` |
| `.inline-flex` | `display: inline-flex` |
| `.flex-row`, `.flex-column` | `flex-direction` |
| `.flex-wrap`, `.flex-nowrap` | `flex-wrap` |
| `.justify-start`, `.justify-center`, `.justify-end`, `.justify-between`, `.justify-around`, `.justify-evenly` | `justify-content` |
| `.align-start`, `.align-center`, `.align-end`, `.align-stretch`, `.align-baseline` | `align-items` |
| `.flex-1` | `flex: 1 1 0` on a child, so it takes the remaining space |
| `.flex-none` | `flex: none` on a child, so it keeps its content size |
| `.flex-initial` | `flex: 0 1 auto` on a child, so it sizes from its content, can shrink and does not grow |
| `.shrink-0` | `flex-shrink: 0` on a child, so it never gets narrower than its basis |
| `.shrink-1` | `flex-shrink: 1` on a child, restoring shrinking while keeping its grow factor and basis |
| `.self-start`, `.self-center`, `.self-end`, `.self-stretch` | `align-self` on a child |
| `.self-auto` | `align-self: auto` on a child, so it follows the parent's `align-*` again |
| `.order-first`, `.order-last`, `.order-none` | `order: -1`, `order: 1`, `order: 0` on a child. `.order-none` puts a child back in its source position, so a prefixed one undoes the other two from that size class up. Visual order only: keyboard and screen reader order still follow the source. |
| `.gap-0` to `.gap-6` | `gap` on the spacing scale, `!important`; also works on grid containers |
| `.gap-x-0` to `.gap-x-6` | `column-gap` only, `!important` |
| `.gap-y-0` to `.gap-y-6` | `row-gap` only, `!important` |

```html
<div class="flex align-center justify-between gap-3">
  <h2 class="title-medium my-0">Members</h2>
  <button class="tonal">Invite</button>
</div>

<div class="flex flex-wrap gap-2">
  <span class="chip">Design</span>
  <span class="chip">Research</span>
</div>
```

`justify-*` and `align-*` use `flex-start` and `flex-end`, so they follow the writing direction and mirror in RTL. Components that already lay out their children (app bars, toolbars, card `.actions`, button groups) do not need these classes.

The gap scale is `0`, `0.25rem`, `0.5rem`, `0.75rem`, `1rem`, `1.5rem` and `3rem`. `.gap-x-*` and `.gap-y-*` keep the native meanings of `column-gap` and `row-gap` in grid and flex layouts, including RTL and column-direction flex. There are no `auto` gap values. The grid's `.g-*` classes use a separate scale.

```html
<div class="grid-auto-fill gap-4 medium:gap-x-2" style="--grid-column-min: 8rem">
  <div class="surface-container p-3">Monday</div>
  <div class="surface-container p-3">Tuesday</div>
  <div class="surface-container p-3">Wednesday</div>
  <div class="surface-container p-3">Thursday</div>
  <div class="surface-container p-3">Friday</div>
  <div class="surface-container p-3">Saturday</div>
</div>
```

Both gaps start at `1rem`. From 600px, the column gap becomes `0.5rem` while the row gap stays `1rem`. At the same breakpoint, an axis class overrides `.gap-*` regardless of HTML class order. A later breakpoint's `.gap-*` resets both axes; a later axis class changes only its axis. Use `0` to remove a gap. Avoid conflicting values from the same family at the same breakpoint.

### Window-size prefixes

The display, flex direction, wrap, `.flex-1`, `.flex-none`, `.flex-initial`, `.shrink-0`, `.shrink-1`, `justify-*`, `align-*`, `self-*`, `.order-*`, text alignment (`.start-align`, `.center-align`, `.end-align`), `.line-clamp-*`, `.gap-*`, `.gap-x-*`, `.gap-y-*`, margin and padding classes, and `.hide`, also come with a window-size prefix. Prefixed margin and padding cover all sides, `x`, `y`, top, bottom, start and end; use start and end rather than left and right. A prefixed class applies from that size class up and overrides the unprefixed one, the same way the grid's `m6` and `l4` do. `self-center expanded:self-auto` centers a child until Expanded and then follows the parent's `align-*`.

| Prefix | Applies from |
| --- | --- |
| `medium:` | Medium, 600px |
| `expanded:` | Expanded, 840px |
| `large:` | Large, 1200px |
| `extra-large:` | Extra-large, 1600px |

```html
<!-- Stacked on Compact and Medium, one row from Expanded up. -->
<div class="flex flex-column gap-2 expanded:flex-row expanded:align-center expanded:justify-between">
  <h2 class="title-medium my-0">Members</h2>
  <button class="tonal">Invite</button>
</div>
```

A child can change its share of the space too. This filename sizes from its content below Expanded and takes the remaining space from 840px up. The link keeps its size. `.min-w-0` lets the text shrink below its content minimum so it can truncate. Use `.min-h-0` for a child that must shrink inside a bounded column.

```html
<div class="flex align-center gap-3">
  <span class="flex-initial expanded:flex-1 min-w-0 truncate">Quarterly report with a long filename.pdf</span>
  <a class="button tonal flex-none" href="#overflow">Sizing guide</a>
</div>
```

A prefixed `flex-*` shorthand sets grow, shrink and basis together, so `shrink-0 medium:flex-initial` allows shrinking from 600px up. Use `shrink-0 medium:shrink-1` to restore shrinking while keeping grow and basis. These resets assign explicit values; they do not recover an earlier component rule. Larger active prefixes win regardless of class order. Self-alignment, ordering and the other sizing helpers have no window-size prefixes.

In a Sass selector, escape the colon: `.expanded\:flex-row`. `.medium:hide` hides from Medium up. To show something only from a size up, use the hide-on classes; they are `!important`, so a prefixed display class does not undo them.

### Sizing and overflow

Flex and grid children can retain a minimum size based on their content. Clear that minimum when a child needs to shrink, truncate, or scroll.

| Class | Declaration |
| --- | --- |
| `.min-w-0` | `min-width: 0` |
| `.min-h-0` | `min-height: 0` |
| `.w-full`, `.h-full` | `width: 100%`, `height: 100%` |
| `.max-w-full` | `max-width: 100%` |
| `.aspect-1x1`, `.aspect-4x3`, `.aspect-16x9` | `aspect-ratio`; the height follows the width |
| `.object-cover`, `.object-contain` | `object-fit` on an `<img>` or `<video>` with a set ratio |
| `.overflow-x-auto` | `overflow-x: auto` |
| `.overflow-y-auto` | `overflow-y: auto` |
| `.overflow-hidden` | `overflow: hidden` |

```html
<div class="flex gap-2" style="max-width: 24rem">
  <span class="flex-1 min-w-0 truncate">A long filename that must fit beside its action.pdf</span>
  <button type="button" class="flex-none">Download</button>
</div>

<section class="flex flex-column" style="height: 12rem" aria-labelledby="activity-title">
  <h2 id="activity-title" class="title-medium flex-none">Activity</h2>
  <div class="flex-1 min-h-0 overflow-y-auto p-2" tabindex="0" role="region" aria-label="Recent activity">
    <p>09:00 Report uploaded.</p>
    <p>09:15 Review requested.</p>
    <p>09:30 Comments received.</p>
    <p>09:45 Changes approved.</p>
  </div>
</section>
```

Scrolling needs a bounded size. Use `.overflow-x-auto` for wide tables or action rows. Give a generic scroll region `tabindex="0"` and an accessible name when keyboard users cannot otherwise focus and scroll it. Leave padding for child focus rings, such as `.p-2`. `.overflow-hidden` clips content and focus rings, so use it only when the clipped content is intentionally unavailable visually.

### Borders and corners

| Class | Declaration |
| --- | --- |
| `.bordered` | A 1px `outline-variant` border on every side |
| `.border-top`, `.border-bottom` | The same border on one side |
| `.border-start`, `.border-end` | The same border on the inline start or end, which mirrors in RTL |
| `.corner-none`, `.corner-extra-small`, `.corner-small`, `.corner-medium`, `.corner-large`, `.corner-extra-large`, `.corner-full` | `border-radius` from the matching `--md-sys-shape-corner-*` token |
| `.corner-cut` | `corner-shape: bevel`: the element's corners become straight cuts of the same size. Rounded in browsers without `corner-shape`. |

```html
<div class="bordered corner-large aspect-16x9">16:9</div>
<img class="corner-medium aspect-1x1 object-cover" src="..." alt="..." width="160">
```

`.border` is the outlined card variant and `.rounded` is the pill button, which is why these use other names. On a component, a corner class replaces the component's own shape, including its pressed and selected corner changes.

### Scroll area

Add `.scroll-area` to a bounded container for native scrolling with thin, theme-aware scrollbars. It sets `overflow: auto` for both axes. Wheel, touch, keyboard, and scrollbar dragging remain native; no JavaScript initialization is needed.

```html
<div class="scroll-area p-4" style="width: 12rem; height: 18rem"
  tabindex="0" role="region" aria-labelledby="release-tags-title">
  <h2 id="release-tags-title" class="title-small mt-0">Tags</h2>
  <ul>
    <li>v1.2.0-beta.50</li>
    <li>v1.2.0-beta.49</li>
    <!-- More tags -->
  </ul>
</div>
```

Give a generic scroll region `tabindex="0"` and an accessible name so keyboard users can focus and scroll it. Leave padding for child focus rings. Use `.min-h-0` or `.min-w-0` when a flex or grid child needs to shrink. The class supplies no dimensions, border, padding, or layout.

Add `.scroll-area` to `<html>` to style the page's own scrollbar. The viewport keeps native scrolling, and the thumb color follows the theme.

Override `--scroll-area-thumb-color` and `--scroll-area-track-color` on the container or an ancestor. Defaults are `var(--md-sys-color-on-surface-variant)` and `transparent`. Choose colors with enough contrast against the track and container. Native scrollbar shape, exact width, and automatic hiding depend on the browser and operating system. Older WebKit engines use rounded 8px scrollbars. Forced-colors mode restores system colors and the default width. Selective Sass builds can load `"scroll-area"` through `$utilities`.

### Shapes

Add one `.shape-*` class to cut an element to a rounded shape. The twenty shapes follow the Material 3 Expressive shape library under ExpressiveCSS names: `pebble`, `bean`, `peak`, `dart`, `slice`, `kite`, `cell`, `crest`, `facet`, `spark`, `seal`, `scallop-4`, `scallop-6`, `scallop-7`, `scallop-9`, `scallop-12`, `bloom-4`, `bloom-8`, `spike`, and `flare`. Each is an SVG mask that scales with the element; no JavaScript initialization is needed.

```html
<img class="shape-scallop-9" width="160" src="pug.jpg" alt="A pug wrapped in a blanket">
<button class="icon-button filled large shape-spark" type="button" aria-label="Favorite">
  <span class="material-symbols" aria-hidden="true">favorite</span>
</button>
```

The shapes are drawn for a square box and stretch with a non-square one. On `<img>`, `<video>`, and `<canvas>` the class also sets `aspect-ratio: 1`, `height: auto`, and `object-fit: cover`. Use them on icon buttons, FABs, or buttons with equal width and height. A focused shape draws its focus ring inside its edge, a 3px secondary band and a 2px gap, because the mask clips outlines; images show the gap only. Forced-colors mode paints the band in `Highlight`. The cut-away parts of the box do not take clicks. The class adds `contain: paint`, so overflow, shadows, and borders are clipped to the shape; put `filter: drop-shadow()` on a wrapper for a shadow. Native video controls sit at the box edges and are mostly cut off. Selective Sass builds can load `"shapes"` through `$utilities`.

### Hiding/showing content

We provide easy to use classes to hide/show content on specific screen sizes.

| Class | Screen Range |
| --- | --- |
| `**.hide**` | Hidden for all Devices |
| `.hide-on-compact-only` | Hidden on Compact (`< 600px`) |
| `.hide-on-medium-only` | Hidden on Medium (`600–839px`) |
| `.hide-on-expanded-only` | Hidden on Expanded (`840–1199px`) |
| `.hide-on-large-only` | Hidden on Large (`1200–1599px`) |
| `.hide-on-extra-large-only` | Hidden on Extra-large (`>= 1600px`) |
| `.show-on-compact` | Shown on Compact (`< 600px`) |
| `.show-on-medium` | Shown on Medium (`600–839px`) |
| `.show-on-expanded` | Shown on Expanded (`840–1199px`) |
| `.show-on-large` | Shown on Large (`1200–1599px`) |
| `.show-on-extra-large` | Shown on Extra-large (`>= 1600px`) |
| `.show-on-medium-and-up` | Shown from Medium (`>= 600px`) |
| `.show-on-medium-and-down` | Shown on Compact and Medium (`< 840px`) |
| `.hide-on-print` | Hidden on paper |
| `.print-only` | Hidden on screen, shown on paper |

Printing also uses the light scheme, hides navigation rails, navigation bars, the FAB menu, snackbars and tooltips, prints the address after each external link in running text, and keeps headings with the content that follows them.

#### Usage

```html
<div class="hide-on-compact-only"></div>

<div class="flex hide-on-compact-only gap-2">
  <a href="/reports">Reports</a>
  <a href="/settings">Settings</a>
</div>
```

The flex example is hidden below 600px and keeps its flex layout above that width. Show helpers set `display: block !important` within their range; they do not hide content outside it. Use `.hide` with a show helper only when block display is appropriate.

#### Visually hidden

`visually-hidden` removes an element from sight but keeps it in the accessibility tree, so screen readers still announce it. Use it for text the layout does not show, such as context that makes a repeated link unique or the text of a live region. Like `.hide`, it is `!important`. A focused element stays invisible, so do not put it on a focusable control unless focus is drawn somewhere else, the way a filter chip draws it on its label.

```html
<a href="/orders/42">View<span class="visually-hidden"> order 42</span></a>
```

#### Reveal on focus

Use `.visually-hidden-focusable` for a skip link or a container that should appear when it or a descendant receives focus. It uses the same hidden styles only while focus is outside. Do not combine it with `.visually-hidden`, which would keep it hidden.

```html
<a class="visually-hidden-focusable" href="#main-content">Skip to content</a>
<main id="main-content" tabindex="-1">
  <h1>Page content</h1>
</main>
```

### Spacing

These classes help space elements with margin and padding helpers for all directions. This works by combining a margin/padding prefix, a direction infix and value suffix.

#### Prefix margin and padding modifiers

| Prefix | Modifies |
| --- | --- |
| `**m***` | Modifies the margin according to the infix and suffix values. If no infix is provided, it will be applied to all directions. |
| `**p***` | Modifies the padding according to the infix and suffix values. If no infix is provided, it will be applied to all directions. |

#### Infix direction modifiers

| Infix | Direction |
| --- | --- |
| `***t**` | Applies modifier to the top side of the element |
| `***r**` | Applies modifier to the right side of the element |
| `***b**` | Applies modifier to the bottom side of the element |
| `***l**` | Applies modifier to the left side of the element |
| `***s**` | Applies modifier to the inline-start side, following the writing direction |
| `***e**` | Applies modifier to the inline-end side, following the writing direction |
| `***x**` | Applies modifier to the left and right sides of the element |
| `***y**` | Applies modifier to the top and bottom sides of the element |

#### Suffix values

Append a value suffix to each modifier. Values 0 through 6 work for margin and padding; `auto` is for margin only.

| Suffix | Value |
| --- | --- |
| `***-0**` | `**0**` |
| `***-1**` | `**0.25rem**` |
| `***-2**` | `**0.5rem**` |
| `***-3**` | `**0.75rem**` |
| `***-4**` | `**1rem**` |
| `***-5**` | `**1.5rem**` |
| `***-6**` | `**3rem**` |
| `***-auto**` | `**auto**` |

#### Logical spacing

These helpers use the same scale and `!important` declarations. Inline start and end mirror in RTL. Existing physical spacing classes remain available; avoid combining physical and logical helpers that set the same side.

| Property | Classes |
| --- | --- |
| `margin-inline-start` | `ms-0` through `ms-6`, `ms-auto` |
| `margin-inline-end` | `me-0` through `me-6`, `me-auto` |
| `padding-inline-start` | `ps-0` through `ps-6` |
| `padding-inline-end` | `pe-0` through `pe-6` |

```html
<div class="flex align-center gap-2">
  <span class="start-align">Account</span>
  <a class="ms-auto" href="/settings">Settings</a>
</div>
```

#### Physical spacing

Margin helpers

| Property | Prefix | Classes |  |  |  |  |  |  |  |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `**margin**` | `**m**` | `**m-0**` | `**m-1**` | `**m-2**` | `**m-3**` | `**m-4**` | `**m-5**` | `**m-6**` | `**m-auto**` |
| `**margin-top**` | `**mt**` | `**mt-0**` | `**mt-1**` | `**mt-2**` | `**mt-3**` | `**mt-4**` | `**mt-5**` | `**mt-6**` | `**mt-auto**` |
| `**margin-right**` | `**mr**` | `**mr-0**` | `**mr-1**` | `**mr-2**` | `**mr-3**` | `**mr-4**` | `**mr-5**` | `**mr-6**` | `**mr-auto**` |
| `**margin-bottom**` | `**mb**` | `**mb-0**` | `**mb-1**` | `**mb-2**` | `**mb-3**` | `**mb-4**` | `**mb-5**` | `**mb-6**` | `**mb-auto**` |
| `**margin-left**` | `**ml**` | `**ml-0**` | `**ml-1**` | `**ml-2**` | `**ml-3**` | `**ml-4**` | `**ml-5**` | `**ml-6**` | `**ml-auto**` |
| `**margin-block**` | `**my**` | `**my-0**` | `**my-1**` | `**my-2**` | `**my-3**` | `**my-4**` | `**my-5**` | `**my-6**` | `**my-auto**` |
| `**margin-inline**` | `**mx**` | `**mx-0**` | `**mx-1**` | `**mx-2**` | `**mx-3**` | `**mx-4**` | `**mx-5**` | `**mx-6**` | `**mx-auto**` |
| Values |  | 0 | 0.25rem | 0.5rem | 0.75rem | 1rem | 1.5rem | 3rem | auto |

Padding helpers

| Property | Prefix | Classes |  |  |  |  |  |  |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `**padding**` | `**p**` | `**p-0**` | `**p-1**` | `**p-2**` | `**p-3**` | `**p-4**` | `**p-5**` | `**p-6**` |
| `**padding-top**` | `**pt**` | `**pt-0**` | `**pt-1**` | `**pt-2**` | `**pt-3**` | `**pt-4**` | `**pt-5**` | `**pt-6**` |
| `**padding-right**` | `**pr**` | `**pr-0**` | `**pr-1**` | `**pr-2**` | `**pr-3**` | `**pr-4**` | `**pr-5**` | `**pr-6**` |
| `**padding-bottom**` | `**pb**` | `**pb-0**` | `**pb-1**` | `**pb-2**` | `**pb-3**` | `**pb-4**` | `**pb-5**` | `**pb-6**` |
| `**padding-left**` | `**pl**` | `**pl-0**` | `**pl-1**` | `**pl-2**` | `**pl-3**` | `**pl-4**` | `**pl-5**` | `**pl-6**` |
| `**padding-block**` | `**py**` | `**py-0**` | `**py-1**` | `**py-2**` | `**py-3**` | `**py-4**` | `**py-5**` | `**py-6**` |
| `**padding-inline**` | `**px**` | `**px-0**` | `**px-1**` | `**px-2**` | `**px-3**` | `**px-4**` | `**px-5**` | `**px-6**` |
| Values |  | 0 | 0.25rem | 0.5rem | 0.75rem | 1rem | 1.5rem | 3rem |

#### Usage

```html
<div class="p-2">
  <h5 class="mt-1">The div has a padding 0.5rem and the h5 has a margin-top of 0.25rem</h5>
</div>
```

### Formatting

These classes help format various content on your site.

#### Truncation

To truncate long lines of text in an ellipsis, add the class `truncate` to the tag which contains the text. A flex or grid child may also need `.min-w-0` to shrink.

```html
<h4 class="truncate">This is an extremely long title that will be truncated</h4>
```

#### Line clamp

To cut text to a few lines and end it in an ellipsis, add `line-clamp-1`, `line-clamp-2` or `line-clamp-3`. `line-clamp-none` shows every line again, so `line-clamp-2 expanded:line-clamp-none` clamps on compact and medium windows only. The classes set `display`, so put them on a child of a flex or grid container, not on the container. Pad a wrapper rather than the clamped element, because block-end padding shows the top of the next line.

```html
<p class="line-clamp-2">This card description runs on for several lines…</p>
```

#### Text wrapping

| Class | Use |
| --- | --- |
| `.break-words` | `overflow-wrap: anywhere` lets long URLs or identifiers wrap within their container. |
| `.text-nowrap` | `white-space: nowrap` keeps text on one line without clipping or ellipsis. |
| `.text-wrap` | `white-space: normal` restores ordinary wrapping. |
| `.text-balance` | `text-wrap-style: balance` evens out the line lengths of a short block, such as a card title. Headings already do this. |
| `.text-pretty` | `text-wrap-style: pretty` avoids a single short word on the last line. Paragraphs already do this. |

Allow wrapping on narrow screens when keeping a line intact would hide required text. These classes do not change display or overflow.

#### Opacity

`.opacity-0`, `.opacity-25`, `.opacity-38`, `.opacity-50`, `.opacity-75` and `.opacity-100` set `opacity` to 0, 0.25, 0.38, 0.5, 0.75 and 1. 38 is the M3 opacity for disabled content. A class overrides a component's own opacity, and unlayered page CSS overrides the class.

```html
<img class="opacity-50" src="pattern.svg" alt="">
```

Dimmed text still needs enough contrast unless it belongs to a disabled control. Opacity disables and hides nothing: even at `.opacity-0` the element takes clicks and focus and stays in the accessibility tree. Use `disabled`, `.hide` or `hidden` for that.

#### Unstyled lists

Add `list-unstyled` to a `<ul>` or `<ol>` to remove its markers and its inline-start padding (`list-style: none; padding-inline-start: 0`). The list keeps its margins, and a list nested inside it keeps its own markers. WebKit drops the list role from a list without markers, so add `role="list"` as well. The semantics checks flag a `.list-unstyled` list with no `role`; a list that sets another role on purpose, such as `role="none"`, passes.

```html
<ul class="list-unstyled" role="list">
  <li>Invoices</li>
  <li>Receipts</li>
</ul>
```

#### Hover

The `hoverable` class adds an animation for box shadow as seen below. It can be used on most elements, but is meant for use on cards.

```html
<article class="hoverable">Hoverable Card</article>
```

### Pointer

| Class | Declaration |
| --- | --- |
| `.pointer-events-none` | `pointer-events: none`. Clicks, taps and hovers on the element and its children reach whatever lies beneath it. |
| `.pointer-events-auto` | `pointer-events: auto`. A child of a `.pointer-events-none` element takes pointer input again. |
| `.cursor-pointer` | `cursor: pointer` |

```html
<div class="relative">
  <a href="/photos/42"><img class="w-full" src="..." alt="Fishing boats moored in a harbor at dusk"></a>
  <div class="pointer-events-none flex align-end justify-between p-2" style="position: absolute; inset: 0">
    <span class="label-large">Photo by Ana Ruiz</span>
    <button type="button" class="pointer-events-auto">Hide caption</button>
  </div>
</div>

<tr class="cursor-pointer">
  <td><a href="/orders/42">Order 42</a></td>
  <td>Shipped</td>
</tr>
```

A click on the caption opens the photo link beneath it, and the button still takes its own clicks. `.pointer-events-none` stops the pointer only: the element and its controls stay in the tab order and still respond to keys. To disable a control, use `disabled`; to take a whole region out of use, use `inert`.

`.cursor-pointer` changes the cursor and nothing else. Links and buttons already show it. In the table row above, a page script opens the row's link on a click anywhere in the row, and keyboard users reach the same link by Tab. An element that responds to clicks but isn't a link or button still needs a role, a tab stop and key handling, so use a `<button>` or `<a href>` where one fits.

### Browser defaults

Because we override many of the default browser styles and elements, we provide the `.browser-default` class to opt those elements out of the framework treatment.

| Name of Element | Reverted Style |
| --- | --- |
| SELECT | Browser default select element |
| INPUT | Browser default input |

```html
<select class="browser-default">
  <option value="" disabled selected>Choose your option</option>
  <option value="1">Option 1</option>
</select>
<input class="browser-default" type="text">
```

---

## Media Styles

Responsive images and videos ready to be seen on many devices.

### Images

Images can be styled in different ways using Expressive.

#### Responsive Images

To make images resize responsively to page width, you can add the class `responsive-img` to your image tag. It will now have a `max-width: 100%` and `height: auto`.

```html
<img class="responsive-img" src="cool_pic.jpg">
```

#### Circular images

To make images appear circular, simply add `class="circle"` to them.

```html
<div class="row">
  <div class="s12">
    <article>
      <div class="row align-center">
        <div class="s3">
          <img src="portrait.jpg" alt="" class="circle responsive-img">
        </div>
        <div class="s9">
          <span>
            This is a square image. Add the "circle" class to it to make it appear circular.
          </span>
        </div>
      </div>
    </article>
  </div>
</div>
```

### Videos

We provide a container for embedded videos that resizes them responsively.

#### Responsive Embeds

To make your embeds responsive, wrap them with a containing div which has the class `video-container`.

```html
<div class="video-container">
  <iframe src="https://www.youtube.com/embed/Q8TXgCzxEnw?rel=0" allowfullscreen></iframe>
</div>
```

#### Responsive Videos

To make your HTML5 videos responsive just add the class `responsive-video` to the video tag.

```html
<video class="responsive-video" controls>
  <source src="movie.mp4" type="video/mp4">
</video>
```

---

## Shadow

Raise or flatten an element with the z-depth elevation classes.

In material design, everything should have a certain z-depth that determines how far raised or close to the page the element is.

You can easily apply this shadow effect by adding a `class="z-depth-2"` to an HTML tag. In Sass, include the mixin rather than extending the class: `@include z-depth("2")`. A `z-depth-0` can be used to remove shadows from elements that have z-depths by default.

```html
<div class="row">
  <div class="s12 m4 l3">
    <div class="z-depth-0">0</div>
  </div>
  <div class="s12 m4 l3">
    <div class="z-depth-1">1</div>
  </div>
  <div class="s12 m4 l3">
    <div class="z-depth-2">2</div>
  </div>
  <div class="s12 m4 l3">
    <div class="z-depth-3">3</div>
  </div>
  <div class="s12 m4 l3">
    <div class="z-depth-4">4</div>
  </div>
  <div class="s12 m4 l3">
    <div class="z-depth-5">5</div>
  </div>
</div>
```

#### Sass

The class list is generated from the same map as the mixin, so they cannot drift. Prefer the mixin inside component styles; `@extend .z-depth-2` across files is not supported.

```css
.my-panel {
  @include z-depth("2");
}
```

There is also `z-depth-1-half`, an in-between level from the same elevation map.


#### Elevation tokens

Each level is also a custom property, `--md-sys-elevation-level0` to `--md-sys-elevation-level5` plus `--md-sys-elevation-level1-half`. The classes, the mixin and every component read them, so a theme can soften or deepen a level at runtime.

```css
:root { --md-sys-elevation-level1: 0 1px 3px rgb(0 0 0 / 0.3); }
```

#### Stacking order

A shadow does not decide what sits on top. Surfaces that float over the page take their `z-index` from these properties, lowest first. Dialogs, modal sheets, open menus, an open lightbox and open popovers are in the browser’s top layer, above all of them.

| Property | Value | Used by |
| --- | --- | --- |
| `--expressive-z-toolbar` | 996 | A fixed toolbar. |
| `--expressive-z-app-bar` | 997 | A pinned top app bar and a fixed navigation bar. |
| `--expressive-z-modal-rail` | 998 | An expanded navigation rail over the page. |
| `--expressive-z-sheet` | 1100 | A bottom or side sheet opened without `showModal()`. |
| `--expressive-z-lightbox` | 1200 | Nothing. An open lightbox is in the top layer. |
| `--expressive-z-tooltip` | 2000 | Tooltips. |
| `--expressive-z-menu` | 9999 | The docked date and time pickers. Open menus are in the top layer. |
| `--expressive-z-snackbar` | 10000 | Snackbars. |
| `--expressive-z-confetti` | 10001 | The confetti overlay, in browsers without popovers. |

```css
.site-banner { position: sticky; top: 0; z-index: calc(var(--expressive-z-app-bar) + 1); }
```

---

## Table

Organize data with a few utility classes on a standard HTML table.

Tables are a nice way to organize a lot of data. We provide a few utility classes to help you style your table as easily as possible.

| Name | Item Name | Item Price |
| --- | --- | --- |
| Alvin | Eclair | $0.87 |
| Alan | Jellybean | $3.76 |
| Jonathan | Lollipop | $7.00 |
| Shannon | KitKat | $9.99 |

```html
<table>
  <thead>
    <tr>
      <th>Name</th>
      <th>Item Name</th>
      <th>Item Price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Alvin</td>
      <td>Eclair</td>
      <td>$0.87</td>
    </tr>
    <tr>
      <td>Alan</td>
      <td>Jellybean</td>
      <td>$3.76</td>
    </tr>
    <tr>
      <td>Jonathan</td>
      <td>Lollipop</td>
      <td>$7.00</td>
    </tr>
  </tbody>
</table>
```

### Striped table

Add `class="striped"` to the table tag for a striped table.

| Name | Item Name | Item Price |
| --- | --- | --- |
| Alvin | Eclair | $0.87 |
| Alan | Jellybean | $3.76 |
| Jonathan | Lollipop | $7.00 |
| Shannon | KitKat | $9.99 |

### Highlight table

Add `class="highlight"` to the table tag for a highlight table.

| Name | Item Name | Item Price |
| --- | --- | --- |
| Alvin | Eclair | $0.87 |
| Alan | Jellybean | $3.76 |
| Jonathan | Lollipop | $7.00 |
| Shannon | KitKat | $9.99 |

### Centered table

Add `class="centered"` to the table tag to center align all the text in the table.

| Name | Item Name | Item Price |
| --- | --- | --- |
| Alvin | Eclair | $0.87 |
| Alan | Jellybean | $3.76 |
| Jonathan | Lollipop | $7.00 |
| Shannon | KitKat | $9.99 |

### Responsive table

Add `class="responsive-table"` to the table tag to make the table horizontally scrollable on smaller screen widths.

| Name | Item Name | Item Price |
| --- | --- | --- |
| Alvin | Eclair | $0.87 |
| Alan | Jellybean | $3.76 |
| Jonathan | Lollipop | $7.00 |
| Shannon | KitKat | $9.99 |

---

## Transitions

Animate content in and out with a few CSS classes.

We've made some custom animation classes that will transition your content with only CSS. Each CSS transition consists of a base class that applies the necessary styles and additional classes that control the state of the transition.

#### Scale

Use this to scale in and out elements. Make sure to add the base transition class `scale-transition`. Then add the class `scale-out` to scale the element down until it is hidden. To start something as hidden, add the class `scale-out` first, and then add the class `scale-in` to scale the element up until it is shown.

With reduced motion requested, scale state changes happen immediately. Scaling out is visual only; applications still own disclosure semantics and focus.

```html
<!-- Scaled in -->
<button id="scale-demo" type="button" class="button circle extra scale-transition" aria-label="Add"><span class="material-symbols" aria-hidden="true">add</span></button>
<!-- Scaled out -->
<button id="scale-demo" type="button" class="button circle extra scale-transition scale-out" aria-label="Add"><span class="material-symbols" aria-hidden="true">add</span></button>
```

#### Motion tokens

Components take their easing curves and on-scale durations from the Material 3 `md.sys.motion` tokens. Override one on `:root` to change every component that uses it, or on a subtree to change only what is inside it. Off-scale times, such as the 2.1s progress loop, stay literal.

| Token | Value |
| --- | --- |
| `--md-sys-motion-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` |
| `--md-sys-motion-easing-standard-accelerate` | `cubic-bezier(0.3, 0, 1, 1)` |
| `--md-sys-motion-easing-standard-decelerate` | `cubic-bezier(0, 0, 0, 1)` |
| `--md-sys-motion-easing-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` (CSS cannot draw the emphasized path) |
| `--md-sys-motion-easing-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` |
| `--md-sys-motion-easing-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` |
| `--md-sys-motion-easing-linear` | `cubic-bezier(0, 0, 1, 1)` |
| `--md-sys-motion-duration-short1` to `short4` | 50, 100, 150, 200ms |
| `--md-sys-motion-duration-medium1` to `medium4` | 250, 300, 350, 400ms |
| `--md-sys-motion-duration-long1` to `long4` | 450, 500, 550, 600ms |
| `--md-sys-motion-duration-extra-long1` to `extra-long4` | 700, 800, 900, 1000ms |

```css
:root { --md-sys-motion-duration-short4: 150ms; } /* every 200ms transition runs at 150ms */
```

When the user asks for reduced motion, every duration token and every spring `-duration` token becomes `0s`, so each component that reads them changes state without moving. There is no `!important`, so a page can opt an element back in. Off-scale literal times are not tokens; their components cancel them in their own reduced-motion rules.

#### Springs

Material 3 Expressive describes motion as springs: a damping ratio and a stiffness. CSS has no spring timing function, so the framework solves each spring when the sheet is compiled and samples it into a `linear()` easing that runs for the time the spring takes to settle. Every spring has an easing token and a `-duration` token. Use the two together.

| Token | Standard (damping, stiffness) | Expressive |
| --- | --- | --- |
| `--md-sys-motion-spring-fast-spatial` | 0.9, 1400 | 0.6, 800 |
| `--md-sys-motion-spring-default-spatial` | 0.9, 700 | 0.8, 380 |
| `--md-sys-motion-spring-slow-spatial` | 0.9, 300 | 0.8, 200 |
| `--md-sys-motion-spring-fast-effects` | 1, 3800 | same |
| `--md-sys-motion-spring-default-effects` | 1, 1600 | same |
| `--md-sys-motion-spring-slow-effects` | 1, 800 | same |

The standard motion scheme is the default. `motion="expressive"` on the root or on any subtree switches the spatial springs to the bouncier expressive ones; `motion="standard"` switches a subtree back. Effects springs, for color and opacity, never overshoot and are the same in both schemes.

```css
.panel {
  transition: translate var(--md-sys-motion-spring-default-spatial-duration)
    var(--md-sys-motion-spring-default-spatial);
}
```

In Sass, `spring($damping, $stiffness)` and `spring-duration($damping, $stiffness)` turn any spring into the same pair.

```scss
.drawer {
  transition: translate spring-duration(0.8, 380) spring(0.8, 380);
}
```

A `linear()` easing always starts from rest, so a transition interrupted part way restarts without the speed it had. A dragged bottom or side sheet solves its spring at run time instead: released short of dismissing, it springs back on the default spatial spring from where the finger left it at the finger's speed, and a press while it moves catches it where it is. Reduced motion keeps the instant snap, and a sheet whose `--md-comp-bottom-sheet-motion` or `--md-comp-side-sheet-motion` tokens a page overrode keeps the CSS transition those ask for. The spring ends early if the sheet closes or reduced motion is turned on. Each spring also has `--md-sys-motion-spring-*-damping` and `-stiffness` tokens with its two numbers, following `motion`, for scripts that solve a spring themselves.

---

## Typography

Material Design 3 type, from the HTML.

The type scale is the HTML. `<h1>`–`<h6>` are display and headline roles, `<p>` is body-large, `<small>` is body-small. You do not need a class unless the element cannot carry the role — a `<span class="label-large">`, or a `<p class="display-large">` used as a hero numeral.

Tokens follow the [M3 type system](https://m3.material.io/styles/typography/overview). Each role sets family, size, weight, line-height, and letter-spacing from `--md-sys-typescale-*`. They do not set `font-style` — the token named `-font-family-style` holds “Regular” / “Medium”, which are weights, not CSS `font-style` keywords.

M3's `--md-ref-typeface-brand` and `--md-ref-typeface-plain` tokens both default to Roboto. Large display, headline, and title-large roles use brand; smaller title, body, and label roles use plain. Noto Sans is the fallback for characters Roboto cannot cover. Roboto ships Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek Extended and Vietnamese, and Noto Sans adds Devanagari, each at 400, 500 and 700. Every subset is its own face with a `unicode-range`, so the browser downloads only the scripts and weights the page renders. Noto Sans Arabic, Noto Sans Hebrew and Noto Sans Thai come next in the fallback, each with only its script's `unicode-range`, so a page fetches one only when it shows that script. CJK text falls through to the system font the browser picks for the element's `lang`, so set `lang` on Japanese, Chinese and Korean text. For another script, load its family and add it to `--md-ref-typeface-fallback` before `sans-serif`. Override the reference tokens to use a different family.

### Semantic map

```html
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<p>A paragraph uses body-large.</p>
<small>Small print.</small>
```

| Element | Role | Size | Weight |
| --- | --- | --- | --- |
| `h1` | display-small | 2.25rem | 400 |
| `h2` | headline-large | 2rem | 400 |
| `h3` | headline-medium | 1.75rem | 400 |
| `h4` | headline-small | 1.5rem | 400 |
| `h5` | title-large | 1.375rem | 400 |
| `h6` | title-medium | 1rem | 500 |
| `p` | body-large | 1rem | 400 |
| `small`, `figcaption` | body-small | 0.75rem | 400 |
| `body` | body-medium | 0.875rem | 400 |

### The fifteen roles

When the tag cannot say the role, use the class. Display-large and display-medium have no heading — they are for short, important numerals and hero lines.

```html
<p class="display-large">Display Large</p>
<span class="label-large">Label</span>
```

| Class | Size | Weight | Line height | Tracking |
| --- | --- | --- | --- | --- |
| `display-large` | 3.5625rem | 400 | 4rem | −0.015625rem |
| `display-medium` | 2.8125rem | 400 | 3.25rem | 0 |
| `display-small` | 2.25rem | 400 | 2.75rem | 0 |
| `headline-large` | 2rem | 400 | 2.5rem | 0 |
| `headline-medium` | 1.75rem | 400 | 2.25rem | 0 |
| `headline-small` | 1.5rem | 400 | 2rem | 0 |
| `title-large` | 1.375rem | 400 | 1.75rem | 0 |
| `title-medium` | 1rem | 500 | 1.5rem | 0.009375rem |
| `title-small` | 0.875rem | 500 | 1.25rem | 0.00625rem |
| `body-large` | 1rem | 400 | 1.5rem | 0.03125rem |
| `body-medium` | 0.875rem | 400 | 1.25rem | 0.015625rem |
| `body-small` | 0.75rem | 400 | 1rem | 0.025rem |
| `label-large` | 0.875rem | 500 | 1.25rem | 0.00625rem |
| `label-medium` | 0.75rem | 500 | 1rem | 0.03125rem |
| `label-small` | 0.6875rem | 500 | 1rem | 0.03125rem |

### Emphasis

`<em>` is italic. `<strong>` and `<b>` are weight 500 (M3 Medium). Helpers cover the rest when the tag is already taken.

*Emphasized*, **strong**, italic, bold, light, thin, underline, upper, capitalize this.

```html
<em>Emphasized</em>
<strong>strong</strong>
<span class="bold">bold</span>
<span class="upper">upper</span>
```

`body-large`, `body-medium`, and `body-small` apply the three body roles to an element that is not a `<p>` or `<small>`.

Each of the fifteen roles has an emphasized counterpart from Material 3 Expressive: the same family, size and spacing, heavier. Add `-emphasized` to the role class, such as `.label-large-emphasized` or `.headline-small-emphasized`. Display, headline, title-large and body roles go to `500`; title-medium, title-small and label roles go to `700`. The weight is a token, `--md-sys-typescale-emphasized-{role}-font-weight`. Roboto and Noto Sans are bundled at `700` and load only when a page uses it.

```html
<h2 class="headline-small-emphasized">Account security</h2>
<span class="label-large-emphasized">Selected</span>
```
### Links

Links use the primary color. A link without a class inside a paragraph, a `<dd>`, a blockquote, a figure caption or a table cell is also underlined, because color alone does not mark a link for everyone. Links elsewhere, and any link with a class, have no underline, so navigation lists, menus, tabs and buttons made from `<a>` keep their own look.

```html
<p>Read the <a href="/themes.html">theme guide</a> first.</p>  <!-- underlined -->
<a class="button tonal" href="/themes.html">Themes</a>         <!-- styled by its class -->
```

### Blockquotes

Blockquotes emphasize a quote or citation. The bar on the start side uses `--md-sys-color-primary`.

```html
<blockquote>
  This is an example quotation that uses the blockquote tag.
</blockquote>
```

### Keyboard keys

`<kbd>` is drawn as a keycap. Wrap a combination in an outer `<kbd>`, which stays plain around the keys inside it. A trailing `<kbd>` in a menu or list item is quiet shortcut text instead.

```html
<p>Press <kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd> to open the command palette.</p>
```

### Flow text

`flow-text` scales font size with the viewport so line length stays readable. Resize the window and watch the sample change, or use the button to compare with unscaled body text.

```html
<p class="flow-text">I am Flow Text</p>
```

### Prose

Add `.prose` to a container of bare HTML, such as rendered Markdown or CMS output. It caps the line length at 70 characters, spaces out headings, indents lists, underlines links in lists and headings, and gives inline `<code>` and `<pre>` blocks a surface. Only elements without a class are styled, so a button or chip inside the content keeps its own look.

```html
<div class="prose">
  <h2>Release notes</h2>
  <p>Run <code>npm ci</code>, then rebuild.</p>
  <ul>
    <li><a href="/helpers.html">Helpers</a></li>
  </ul>
  <pre><code>npm run build</code></pre>
</div>
```

Put `.prose` on a `<div>`, `<section>` or `<main>`. Every `<article>` is a card, so an article with `.prose` would be drawn as one.

### Typeface tokens

Override the brand and plain reference tokens to customize the scale while preserving each role's M3 size, weight, line height, and tracking:

```css
:root {
  --md-ref-typeface-brand: "Roboto Flex";
  --md-ref-typeface-plain: "Roboto";
}
```

The default order is Roboto, Noto Sans, Noto Sans Arabic, Noto Sans Hebrew, Noto Sans Thai, then the generic sans-serif family. For a script none of them covers, put its family before the generic one:

```css
:root { --md-ref-typeface-fallback: "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Bengali", sans-serif; }
``` ExpressiveCSS leaves the root font size to the browser and expresses the M3 scale in rem using the standard 16px conversion, so browser text-size preferences continue to work.

---

## State layers

The translucent overlay a component paints over itself for hover, focus, pressed and dragged.

Material 3 has no ink ripple. A state layer is the translucent wash a component paints over itself
to show that you are hovering it, that it has focus, or that you are pressing it. It is a
*foundation*: you never write markup for one. Components paint it themselves, and these tokens
decide how strong it is.

| Token | Value | Applies when |
| --- | --- | --- |
| `--md-sys-state-hover-state-layer-opacity` | 0.08 | The pointer is over the control. |
| `--md-sys-state-focus-state-layer-opacity` | 0.1 | The control has visible focus. |
| `--md-sys-state-pressed-state-layer-opacity` | 0.1 | The control is being pressed. |
| `--md-sys-state-dragged-state-layer-opacity` | 0.16 | The control is being dragged. |

#### Overriding

Set the token on `:root` to change every component, or on a subtree to change only what is inside
it. Components that expose their own state layer token read from these, so one component can be
reached without touching the rest.

```css
:root { --md-sys-state-hover-state-layer-opacity: 0.12; }
.my-panel { --md-comp-card-hover-state-layer-opacity: 0.04; }
```

Most components draw the layer as an overlay filling the control. Checkboxes and radio buttons have
no room for one, so they draw it as a ring growing out from the control's edge. Both read the same
opacity, so they stay in step.

---

# CSS components

## Badges

Notifications, counts, or status on navigation items and icons.

A `<span class="badge">` is the badge. Empty is the **small** 6dp dot; text is the **large** 16dp stadium. Nest it in the icon so it sits on the upper trailing edge of the 24dp glyph. Limit the label to four characters, including `+`. The default mapping is `error` / `on-error`.

Nesting has a consequence worth stating: **a badge inside a hidden icon is hidden with it.** `aria-hidden="true"` covers the whole subtree, and `aria-hidden="false"` on a descendant does not undo that — so a count that only exists inside a decorative icon is a count nobody hears. Two ways out, depending on what surrounds it:

- Inside a control, put the count in the control's name — `<a aria-label="Inbox, 3 unread">` — and leave the icon decorative.
- Standing alone, make the icon the image: `role="img"` with a label that includes the count.

A badge that sits in the flow beside the label, rather than inside the icon, is announced normally and needs neither.

```html
<span class="material-symbols" role="img" aria-label="Mail, unread">mail<span class="badge"></span></span>
<span class="material-symbols" role="img" aria-label="Mail, 1 unread">mail<span class="badge">1</span></span>
<span class="material-symbols" role="img" aria-label="Mail, 999+ unread">mail<span class="badge">999+</span></span>
```

### Small

Leave the badge empty. It is a 6dp circle with no label.

### Large

Put a number or short label in the badge. Height is 16dp, corners are a stadium, inset is 4dp. Use `999+` when the count is larger than 999.

### On navigation

Nest the badge in the destination icon, or leave it as a sibling — the bar and rail place a sibling on the icon’s upper trailing corner. Tabs take the nested form.

```html
<a href="/inbox" aria-label="Inbox, 3 unread">
  <span class="material-symbols" aria-hidden="true">inbox<span class="badge">3</span></span>
  Inbox
</a>
```

### In a list

A trailing `.badge` in a list row stays in flow on the end.

```html
<ul class="list">
  <li>
    <span class="material-symbols" aria-hidden="true">inbox</span>
    Inbox
    <span class="badge">3</span>
  </li>
</ul>
```

### Color

Default is `error` / `on-error`. Override with a fill + `on-*` pair when the badge is not an error/notification.

```html
<span class="badge">3</span>
<span class="badge primary on-primary-text">1</span>
```

---

## Avatar

A person or account as a circular image, initials or icon, alone or in an overlapping group.

A `.avatar` is an `<img>`, or a `<span>` holding initials or an icon. It is 40px by default; `small` is 32px and `large` is 56px, and the initials and icon scale with it. Give an image alt text naming the person, or `alt=""` when the name is already beside it. Initials read as separate letters, so give a `<span>` avatar `role="img"` and an `aria-label`, or `aria-hidden="true"` when the name is visible next to it. CSS only; there is no JavaScript.

```html
<img class="avatar" src="ada.jpg" alt="Ada Lovelace">
<span class="avatar" role="img" aria-label="Grace Hopper">GH</span>
<span class="avatar large" role="img" aria-label="Guest">
  <span class="material-symbols" aria-hidden="true">person</span>
</span>
```

### Avatar group

`.avatar-group` overlaps its avatars by 8px and rings each one in the surface color. End with a count avatar for the people not shown, and say what the group is in the surrounding text.

```html
<div class="avatar-group">
  <img class="avatar small" src="ada.jpg" alt="Ada Lovelace">
  <span class="avatar small" role="img" aria-label="Grace Hopper">GH</span>
  <span class="avatar small" role="img" aria-label="3 more people">+3</span>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-avatar-size` | 40px; `small`: 32px; `large`: 56px |
| `--md-comp-avatar-container-color` | `secondary-container` |
| `--md-comp-avatar-color` | `on-secondary-container` |
| `--md-comp-avatar-group-overlap` | 8px (on `.avatar-group`) |
| `--md-comp-avatar-group-ring-color` | `surface` (on `.avatar-group`) |

---

## Banner migration

The banner component has been removed. Remove `.banner`, its `rich`, `vibrant` and `square` modifiers, and `--md-comp-banners-*` overrides from former banner markup. These modifiers may still serve other components.

Use an `.alert` for persistent nonblocking messages, snackbar for temporary feedback, and dialog for decisions that must block progress. Keep dynamic status text in an existing `role="status"` node with action buttons outside it. Static messages need no live region. The application owns updates, dismissal and logical focus recovery. Never use the page-header landmark `role="banner"` for a message.

## Breadcrumbs

Show the current location when the page sits several layers deep.

Breadcrumbs are a good way to display your current location. This is usually used when you have multiple layers of content.

### Basic

```html
<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/">Home</a></li>
    <li><a href="/library">Library</a></li>
    <li><a href="/library/data" aria-current="page">Data</a></li>
  </ol>
</nav>
```

### Navbar style

```html
<header>
  <nav aria-label="Breadcrumb">
    <ol>
      <li><a href="/">Home</a></li>
      <li><a href="/library">Library</a></li>
      <li><a href="/library/data" aria-current="page">Data</a></li>
    </ol>
  </nav>
</header>
```

---

## Buttons

Material Design 3 common buttons, icon buttons, and FABs — from the HTML.

A `<button>` is a filled common button. An `<a class="button">` is the same thing for a link — on an anchor the class is `button`; the older `.btn` spelling only ever worked on a `<button>`, which is styled as one anyway. Put a `<span class="material-symbols" aria-hidden="true">` icon before or after the label and wrap the label in its own `<span>` — there is no `icon-left` / `icon-right` class, the order of the two spans is the placement. Add `circle` for a round common button and name it with `aria-label`, since the icon is hidden. Icon buttons are `.icon-button`.

Two independent axes dress it: a style (`filled`, `tonal`, `outlined`, `elevated`, `text`) and a size (`xsmall` through `xlarge`). Any style combines with any size — nine classes, not twenty-five, because the size sets the geometry and the style sets the color.

Tokens follow the [M3 button spec](https://m3.material.io/components/buttons/specs). The default is the small size: 40dp tall, label `label-large`, fully round corners, a 20dp icon on an 8dp gap, and a symmetric 16dp inset. State layers are 8% hover and 10% focus or press. Disabled is `on-surface` at 38% on a 12% container.

A label that does not fit wraps between words and the button grows taller, so enlarged text, 200% zoom and narrow columns keep the label inside the button and the button inside its parent. The size's height is the minimum, so a label that fits looks the same. Keep the label in its own `<span>` so a single word longer than the parent can break as well. `circle` buttons, FABs, extended FABs, Timepicker AM/PM controls, and buttons in button groups, split buttons, toolbars, top app bars, dialog and sheet headers, navigation bars and rails, tabs, pagination and snackbars stay on one line at a fixed height. A rule that gives a button a smaller `height` must set `min-height` too, or set `--md-comp-filled-button-container-height` instead.

```html
<button>Create</button>
<button>
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
<button>
  <span>Send</span><span class="material-symbols" aria-hidden="true">send</span>
</button>
<button class="circle" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>

<a class="button" href="/docs">Docs</a>
```

### Filled

High emphasis. This is the default — the main action on a page. Container `primary`, label `on-primary`, no elevation. It has a class of its own, `filled`, so the style axis reads as five names rather than four and a silence; writing it changes nothing.

```html
<button>Create</button>
<button class="filled">Create</button>
<button>
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
<a class="button" href="/docs">Docs</a>
```

### Tonal

Medium emphasis. Add `tonal`. Container `secondary-container`, label `on-secondary-container`.

```html
<button class="tonal">Create</button>
<button class="tonal">
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
```

### Outlined

Medium emphasis, no fill. Add `outlined` (or `border`). Outline is `outline`, label is `primary`.

```html
<button class="outlined">Create</button>
```

### Elevated

Medium emphasis with a shadow. Container `surface`, label `primary`, elevation 1 at rest and 2 on hover. Use sparingly so the page does not fill with shadows.

```html
<button class="elevated">Create</button>
```

### Text

Low emphasis. Add `text` (or `transparent`, or the older `btn-flat`). No container, label `primary`. Use these inside cards and dialogs so they do not stack shadows.

```html
<button class="text">Create</button>
```

### Icon buttons

`.icon-button` is its own component, documented below. `circle` on a common button is the older round common-button form and still works. It follows the size ladder, 40dp by default. Default is filled, `text` is the standard (transparent) one, `tonal` and `outlined` match the common-button colors. Its glyph is the button ladder's 20dp, not the icon button's 24dp — `.icon-button` is the control that carries those.

```html
<button class="circle" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>
<button class="circle tonal" aria-label="Add">…</button>
<button class="circle outlined" aria-label="Add">…</button>
<button class="circle text" aria-label="Add">…</button>
```

### Floating

A FAB is `circle extra` or `circle large`: 56dp, 16dp corners, `primary-container`, elevation 3. The sizes are `circle extra medium` (80dp, 20dp corners, 26dp icon) and `circle extra large` (96dp, 28dp corners, 36dp icon) — the large size needs the `extra`, because `circle large` on its own is the alias for the default 56dp FAB. `extend small` is the extended FAB — icon plus label at 56dp, 16dp corners, 8dp between icon and label. Its required sizes are `extend small` (56dp on a symmetric 16dp inset, `title-medium` label), `extend medium` (80dp, 20dp corners, 28dp icon, `title-large`) and `extend large` (96dp, 28dp corners, 36dp icon, `headline-small`). Sizeless `extend` and the 40dp small FAB have been removed. Its container colour is a role: `primary-container` is the default, and `secondary-container` and `tertiary-container` recolour the label and the state layers with it. On an `<a>`, add `button` — the size classes only match `button` or `a.button`.

```html
<button class="circle extra" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>
<button class="circle extra medium" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>
<button class="circle extra large" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>
<button class="extend small">
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
<button class="extend medium">
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
<button class="extend large secondary-container">
  <span class="material-symbols" aria-hidden="true">add</span><span>Create</span>
</button>
```

### Sizes

Five sizes. `small` is the default at 40dp with a 20dp icon, so it needs no class. The rest are `xsmall` (32dp, 20dp icon), `medium` (56dp, 24dp, `title-medium` label), `large` (96dp, 32dp, `headline-small`) and `xlarge` (136dp, 40dp, `headline-large`). The inset and the icon gap grow with the size, and an `outlined` button's border thickens with it — 1dp up to medium, 2dp large, 3dp extra large. The ladder is the common button's, and `circle` is on it: a `circle` is a round common button, so it takes its rung's box *and* its rung's glyph — 40dp with a 20dp icon by default, 136dp with a 40dp icon at `xlarge`. Only `circle extra` / `circle large` step off it, because those are FAB sizes with a ladder of their own. If you want the icon *button*'s numbers — 40dp with a 24dp icon — that component is `.icon-button`.

```html
<button class="xsmall">Extra small</button>
<button>Small</button>
<button class="medium">Medium</button>
<button class="large">Large</button>
<button class="xlarge">Extra large</button>
```

The two axes are written side by side — there is no per-combination class, so anything on one list goes with anything on the other.

```html
<button class="tonal medium">Tonal medium</button>
<button class="outlined xsmall">Outlined extra small</button>
<button class="text medium">Text medium</button>
```

For a form submit, use a real `<button type="submit">` rather than an input.

```html
<button type="submit">
  <span>Submit</span><span class="material-symbols" aria-hidden="true">send</span>
</button>
```

### Shape and toggles

A button is round; `square` gives it its size's squared corner, 12dp up to small, 16dp at medium and 28dp at large and xlarge. Pressed, every common button morphs to its size's pressed corner, 8dp up to small, 12dp at medium and 16dp beyond, on Material's fast spatial spring (none under reduced motion). `aria-pressed` makes a button a toggle: selected, a round button goes square and a square one goes round, and the colours change from md.comp.button.<style>.{unselected,selected} — filled surface-container to primary, tonal secondary-container to secondary, outlined to inverse-surface without its outline, elevated surface-container-low to primary. Text buttons do not toggle. Switch `aria-pressed` in your own click handler. Buttons in fixed slots (FABs, button group items, toolbars, navigation, tabs, pagination, snackbars, dialog headers and the time picker's AM/PM) keep their own shapes and colours.

```html
<button class="square">Square</button>
<button type="button" class="tonal" aria-pressed="true">Selected</button>
<button type="button" class="square" aria-pressed="false">Unselected square</button>
```

### Disabled

The `disabled` attribute, or the `disabled` class on a link. Applies to every variant.

```html
<button disabled>Filled</button>
<button class="tonal" disabled>Tonal</button>
<a class="button disabled">Link</a>
```

---

## Icon buttons

One action as one icon. Add `icon-button` to a `<button>`, or to an `<a>` when it navigates, and put a single `<span class="material-symbols" aria-hidden="true">` inside. The icon is hidden, so `aria-label` is the only name the control has — an icon button without one fails the semantics suite.

It is a component, not a modifier on the common button: M3 gives it its own token families (`md.comp.icon-button.{xsmall…xlarge}` and `md.comp.icon-button-{standard,filled,tonal,outlined}`), so `.icon-button` takes none of the common-button geometry. Do not write `class="button icon-button"`.

```html
<button type="button" class="icon-button" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>

<a class="icon-button" href="/inbox" aria-label="Inbox">
  <span class="material-symbols" aria-hidden="true">inbox</span>
</a>
```

### Styles

Four, and the modifier names are the common button's. Standard is the default — no container, `on-surface-variant` icon. `filled` is `primary` / `on-primary`, `tonal` is `secondary-container` / `on-secondary-container`, `outlined` is an `outline-variant` border with no fill. M3 gives icon buttons no elevated style, and `text` is what standard already is, so neither class exists here.

```html
<button type="button" class="icon-button filled" aria-label="Add">…</button>
<button type="button" class="icon-button tonal" aria-label="Add">…</button>
<button type="button" class="icon-button outlined" aria-label="Add">…</button>
```

### Sizes, width and shape

Five sizes: `xsmall` (32dp container, 20dp icon), the default 40dp/24dp needing no class, `medium` (56/24), `large` (96/32) and `xlarge` (136/40). They compose with the styles.

Width is the inset plus the icon rather than a size of its own, which is why every size's default width equals its height. `narrow` tightens the inset, `wide` opens it up, and both follow whichever size they are on.

`square` swaps the round container for the squared one — a 12dp corner on the two small sizes, 16dp on medium, 28dp on the two large ones. Either shape tightens its corner while pressed; that morph is Material 3 Expressive's, and it is CSS, with no script behind it.

```html
<button type="button" class="icon-button tonal large" aria-label="Add">…</button>
<button type="button" class="icon-button medium narrow" aria-label="Add">…</button>
<button type="button" class="icon-button filled square" aria-label="Add">…</button>
```

### Disabled and tokens

`disabled` on the `<button>`: the icon drops to `on-surface` at 38%, and a style with a container drops that to 10%. An `<a href>` cannot be disabled and no class makes it so — `pointer-events: none` stops the pointer only, leaving the link in the tab order and still navigable by Enter. Drop the `href`, or the link. `aria-disabled="true"` gets the same painting for an element that must stay put and announce itself as unavailable.

Every value is a `--md-comp-icon-button-*` custom property — `container-height`, `icon-size`, `leading-space`, `trailing-space`, `narrow-space`, `wide-space`, `container-shape`, `container-shape-square`, `pressed-container-shape`, `outline-width`, `color`, `container-color`. The size classes are nothing but a block of those values, so overriding them is how you write a size of your own. The state layer is the icon colour mixed into the container at the `md.sys.state` opacity, so a new style is two tokens.

```html
<button type="button" class="icon-button filled" style="--md-comp-icon-button-container-height: 48px" aria-label="Add">
  <span class="material-symbols" aria-hidden="true">add</span>
</button>
```

### Copy button

Any button copies to the clipboard with `data-copy-for`, naming the id of a field or element, or with `data-copy`, holding the text itself. A field copies its value; other elements copy their trimmed text. A snackbar confirms the result, and screen readers announce it. `data-copy-message` and `data-copy-error` replace its texts for one button; `Expressive.Copy.i18n` (`copied`, `failed`) replaces them everywhere. The root bundle starts the `Copy` behavior on load; with the modular entry, call `Copy.Init()`. The clipboard needs a secure context (HTTPS or localhost); elsewhere the snackbar reports the failure.

```html
<input id="api-key" value="sk-123" readonly aria-label="API key">
<button type="button" class="icon-button" data-copy-for="api-key" aria-label="Copy API key">
  <span class="material-symbols" aria-hidden="true">content_copy</span>
</button>

<button type="button" class="button text" data-copy="https://example.com/share/42" data-copy-message="Link copied">Copy link</button>
```

---


## Button groups

A Material 3 Expressive button group places related commands or toggle buttons in one visual cluster. The root is a `<div class="button-group">`; direct children can be common buttons or `.icon-button` controls. `ButtonGroup` is initialized by `Expressive.AutoInit()` to coordinate standard press widths. Selection management is opt-in through `data-selection`.

Common buttons and icon buttons both inherit group sizing and participate in standard growth and connected shape morphing. Standard press growth is 15% of the item's measured width and is taken from its immediate neighbors, keeping the group's total width stable. An explicit size class on a child overrides the group size. Use a visible container style: filled, tonal, outlined, or elevated for common buttons; filled, tonal, or outlined for icon buttons. Material warns against text buttons and standard icon buttons in groups because their transparent containers hide the shape interaction.

Action-group items are direct controls: a `<button>`, or an `<a class="button" href>` when it navigates. Selection groups accept only native `<button type="button">` children, each with `aria-pressed`. A wrapper loses the gap and connected corners. Every icon-only item carries its own accessible name because the icon is `aria-hidden`.

```html
<div class="button-group" data-selection="multiple" role="group" aria-label="Text formatting">
  <button type="button" class="button tonal" aria-pressed="false">
    <span class="material-symbols" aria-hidden="true">format_bold</span>
    Bold
  </button>
  <button type="button" class="button tonal" aria-pressed="false">
    <span class="material-symbols" aria-hidden="true">format_italic</span>
    Italic
  </button>
</div>
```

Standard is the default: every item keeps its own separate shape, the gap closes as the buttons grow (18dp at `xsmall`, 12dp at `small`, and 8dp at the three largest), and pressing an item widens it while morphing toward the square shape for its size.

Add `connected` for equal-width toggle items that fill the available width. Connected groups are selection controls, not one-shot action rows, so pair the class with `data-selection`. Items sit 2dp apart at every size; the ends stay round and the joins are squared off. Pressing an item changes its inner corners from 8dp to 4dp at the three smaller sizes, 16dp to 12dp at `large`, and 20dp to 16dp at `xlarge`. Add `square` to use the size-specific inner corner on the outer edge too. If unwrapped translated labels need more room than the containing block, the group expands; place it in a container with `overflow-x: auto` and `padding: 5px` to keep every label and keyboard focus ring reachable on narrow screens.

```html
<div class="button-group connected" data-selection="single" data-selection-required role="group" aria-label="Text alignment">
  <button type="button" class="icon-button tonal" aria-label="Align left" aria-pressed="true">
    <span class="material-symbols" aria-hidden="true">format_align_left</span>
  </button>
  <button type="button" class="icon-button tonal" aria-label="Align center" aria-pressed="false">
    <span class="material-symbols" aria-hidden="true">format_align_center</span>
  </button>
</div>
```

The five button sizes — `xsmall`, `small` (the default), `medium`, `large`, `xlarge` — are written once on the group and propagate to common buttons and icon buttons. An explicit child size still wins. The 32dp and 40dp visual controls retain a minimum 48dp interaction target.

The group is not a composite widget. A role such as `toolbar` promises arrow-key navigation this component does not implement. Items are reached with Tab; native toggle buttons activate with Space or Enter. When the group needs a name, use `role="group"` with `aria-label` or `aria-labelledby`.

```html
<div class="button-group medium" role="group" aria-label="Text style">
  <button class="button">One</button>
  <button class="button">Two</button>
</div>
```

| Token | Default |
| --- | --- |
| `--md-comp-button-group-between-space` | 12px |
| `--md-comp-button-group-pressed-item-width-multiplier` | .15 |
| `--md-comp-button-group-item-shape-round` | 9999px |
| `--md-comp-button-group-item-shape-square` | 12px |
| `--md-comp-button-group-pressed-item-shape` | 8px |
| `--md-comp-button-group-container-shape` | 9999px (connected) |
| `--md-comp-button-group-inner-corner-corner-size` | 8px (connected) |
| `--md-comp-button-group-pressed-inner-corner-corner-size` | 4px (connected) |
| `--md-comp-button-group-selected-inner-corner-corner-size` | Half the item height (connected) |

For selection, use `data-selection="single"` or `data-selection="multiple"`; add `data-selection-required` when at least one item must remain on. Author each item's initial `aria-pressed="true|false"`, then call `Expressive.AutoInit()`. Selection changes color, shape, and icon fill, so color is not the only cue. Toggle colors retain hover, focus, pressed, disabled, and focus-ring feedback. Reduced-motion preferences remove width and corner transitions.

---

## Split button

One action the user will take most of the time, next to a menu of the ones they might take instead — Save and "save as", Reply and "reply all", Export and every other format. The root is a `<div class="split-button">` holding two buttons: a leading one that does the work, and a trailing `.menu-trigger` that opens a `<menu>`. The pair sits 2dp apart, the outside is round, and the seam between them is square-ish.

The trailing half is an ordinary Menu trigger, so everything Menu does it does here — `Expressive.AutoInit()` starts it, `data-target` names the surface, and the menu's keyboard model, alignment and options are unchanged. This component adds no script of its own. The one thing Menu does differently is width: a trigger narrower than the surface's own 112dp minimum is not a constraint it can honour, and this trigger is a chevron in a 48dp box, so the menu is sized to its own content instead.

```html
<div class="split-button">
  <button class="button">Save</button>
  <button class="button menu-trigger" data-target="save-menu" aria-label="More save options">
    <span class="material-symbols" aria-hidden="true">arrow_drop_down</span>
  </button>
  <menu id="save-menu">
    <li><button type="button">Save a copy</button></li>
    <li><button type="button">Save as template</button></li>
  </menu>
</div>
```

Both halves are buttons, so every style class works on them — but use the same one on both, or the pair stops reading as one shape.

Opening the menu does two things to the trailing half: the chevron turns over, and the seam swells to fully round, so the pair reads as two separated buttons for as long as the menu is up. Both are drawn from `aria-expanded`, which Menu writes — never author it, since the shape is drawn from it and an authored `"true"` draws an open split button over a closed menu. Hovering or pressing either half swells that half's own inner corner part of the way: 8dp at `xsmall`, 12dp through `medium`, 20dp at the two largest. All of it stops under `prefers-reduced-motion`.

The five button sizes — `xsmall`, `small` (the default), `medium`, `large`, `xlarge`, 32 through 136dp tall — go on the container, never on a half. The trailing half is narrower than the leading one and its chevron is drawn larger than a button icon of the same size (22dp against 20 at the bottom of the ladder, 50dp against 40 at the top), which keeps it 48dp wide at the two smallest sizes and square at the three largest.

The container declares no role. A composite role such as `toolbar` promises arrow-key navigation, and this component *rejects* it rather than withholding it: the two halves are independent commands reached with Tab. The one composite widget here is the `<menu>`, which carries its own role and its own keyboard model. The trailing half's chevron is `aria-hidden`, so the button needs an `aria-label` naming what the menu contains — "More save options", not "More".

Exactly two controls, lead action first and trigger second, both direct children. An `.icon-button` is *not* a half: it is its own component, setting its height, insets, colours and icon sizing on the element, so it reaches none of the split button's geometry — write `<button class="button menu-trigger">`. Order is stated only by the markup, since the halves are told apart by role rather than by position (which is what lets the `<menu>` sit inside the container): a trigger written first still takes the trailing side's round outer corners while flex puts it on the leading side, and the seam ends up on the wrong edge. The count is held from both ends: a third control gets the leading half's rules too, and a container holding only the trigger leaves it squared off along a seam with nothing to join to — one control that opens a menu is just a menu trigger, so drop the container. Peer actions are a button group, related ones belong in the menu.

```html
<div class="split-button medium">
  <button class="button tonal">Reply</button>
  <button class="button tonal menu-trigger" data-target="reply-menu" aria-label="More reply options">
    <span class="material-symbols" aria-hidden="true">arrow_drop_down</span>
  </button>
  <menu id="reply-menu">
    <li><button type="button">Reply all</button></li>
    <li><button type="button">Forward</button></li>
  </menu>
</div>
```

| Token | Default |
| --- | --- |
| `--md-comp-split-button-between-space` | 2px |
| `--md-comp-split-button-container-shape` | 9999px |
| `--md-comp-split-button-leading-button-trailing-space` | 12px |
| `--md-comp-split-button-trailing-button-space` | 13px |
| `--md-comp-split-button-trailing-icon-size` | 22px |
| `--md-comp-split-button-inner-corner-corner-size` | 4px |
| `--md-comp-split-button-inner-pressed-corner-corner-size` | 12px |

Height, the leading half's outer inset, the outline width and every colour come from the button tokens, which the size class sets on the container for both halves. M3 states a separate leading and trailing inset for the trailing half and separate hovered and pressed seams; each pair is equal at all five sizes, so one token carries both.

---

## Cards

Material Design 3 cards, from the HTML.

An `<article>` is an elevated card. Any heading is the headline, `<p class="subhead">` is the optional subhead, a `<p>` is supporting copy, direct `<img>`, `<picture>`, or `<figure>` is media, and direct `<div class="actions">` is the action row. Include only the slots the content needs. There is no `card-content`, `card-title`, `card-action`, `card` or `card-panel` class—the element is the component, and the action row is not a `<nav>`. Tokens follow the [M3 card spec](https://m3.material.io/components/cards/specs): all variants use 12dp corners; elevated rests at level 1, filled and outlined at level 0. A directly actionable card wraps its primary content in one direct `a.primary-action[href]` and has no second link or control. Horizontal cards preserve that source order, use content height with a 240px minimum unless a size helper fixes the expanded height, and stack below 600px, or in a `.query-container` narrower than 600px, with fixed heights reset to content. During reordering, `.dragged` or `.picked-up` preserves the 16% state layer and dragged elevation while the primary action remains hovered or pressed.

A Card reveal is a disclosure with exactly one identified direct `<aside>` and an enabled, accessibly named `button.card-reveal-trigger[type="button"]` outside that panel. Its `aria-controls` must resolve to the panel, and its closest `<article>` owns it—even when a complete nested card sits inside an outer reveal panel. After accepting the contract, Cards writes `aria-expanded`, marks the closed panel `inert`, closes it with Escape, and returns focus from the panel to the trigger. Rejected, disabled-only, unidentified, and multi-panel disclosures remain visible and unmanaged.

```html
<article>
  <header>
    <h3>Weekend in the mountains</h3>
    <p class="subhead">Three-day itinerary</p>
  </header>
  <p>Explore trails and overlooks.</p>
  <img src="images/mountains.jpg" alt="Mountain valley beneath a cloudy sky">
  <div class="actions">
    <button type="button" class="text">Share</button>
    <button type="button" class="tonal">View trip</button>
  </div>
</article>
```

### Variants

Default is elevated. `filled` uses `surface-container-highest` at rest (no shadow). `outlined` (or `border`) draws a 1dp `outline-variant` stroke over `surface`.

```html
<article>…</article>
<article class="filled">…</article>
<article class="outlined">…</article>
```

### Primary action and states

A static card has no hover treatment. To make the card an entry point, wrap its primary content in a direct `<a class="primary-action">`. That gives the card M3 hover, focus, pressed, and focus-indicator states. A directly actionable card has no other links or buttons. When the content needs several actions, leave the card static and put the controls in `.actions`. Toggle `dragged` or `picked-up` during reordering; that state retains its dragged elevation and 16% state layer while the primary action remains hovered or pressed. For a disabled destination, remove `href` and set both `aria-disabled="true"` and `tabindex="-1"` on the primary action.

```html
<article>
  <a class="primary-action" href="/reservation/42">
    <h3>Upcoming reservation</h3>
    <p>Open the reservation details.</p>
  </a>
</article>
```

### Collections

`card-collection` creates a responsive grid with no more than 8dp between cards. Cards keep their variant-specific resting elevation: level 1 for elevated, level 0 for filled and outlined. Add `picked-up` or `dragged` only while a card is being moved. Add `list`, `staggered`, `mosaic`, or `carousel uncontained` for the other collection layouts. Sorting and filtering controls stay outside the collection.

```html
<!-- Responsive grid (the default). Override the track token as needed. -->
<section class="card-collection" aria-label="Dinner menu">
  <article class="outlined">…</article>
  <article class="outlined">…</article>
</section>

<!-- List -->
<section class="card-collection list" aria-label="Dinner menu">
  <article class="outlined"><h3>Pho</h3><p>$12</p></article>
  <article class="outlined"><h3>Quinoa Salad</h3><p>$10</p></article>
</section>

<!-- Intrinsic-height staggered grid -->
<section class="card-collection staggered" aria-label="Dinner menu">
  <article class="outlined"><h3>Pho</h3><p>$12</p></article>
  <article class="outlined"><h3>Combo #2</h3><p>Two entrées, three sides, and two drinks · $28</p></article>
</section>

<!-- Dense mosaic. Set spans per card from application data. -->
<section class="card-collection mosaic" aria-label="Dinner menu">
  <article style="--md-comp-card-collection-row-span: 3">…</article>
  <article style="--md-comp-card-collection-column-span: 2; --md-comp-card-collection-row-span: 4">…</article>
</section>

<!-- M3 uncontained carousel. AutoInit starts the Carousel component. -->
<section class="card-collection carousel uncontained" aria-label="Dinner menu">
  <article class="outlined carousel-item"><h3>Pho</h3><p>$12</p></article>
  <article class="outlined carousel-item"><h3>Quinoa Salad</h3><p>$10</p></article>
</section>
```

### Media

A direct `<img>` is full-bleed across the top and its media surface has rounded corners matching the card. Wrap it in a `<figure>` if you want a caption on the image. The `<figcaption>` uses the complete title-medium type role, including letter-spacing. Its tracking follows the title-medium token and can affect text width or wrapping. The caption is an opaque, rounded bounding shape using the paired `surface` and `on-surface` roles, so image colors cannot reduce the contrast of its text or icons. Normal text must retain at least 4.5:1 contrast; large text and meaningful icons require at least 3:1. Recheck those ratios if you override either color token.

```html
<article>
  <figure>
    <img src="images/sample-1.jpg" alt="">
    <figcaption>
      <span class="material-symbols" aria-hidden="true">landscape</span>
      <span>Card title</span>
    </figcaption>
  </figure>
  <p>I am a very simple card.</p>
  <div class="actions">
    <button type="button" class="text">Action</button>
  </div>
</article>
```

### Horizontal

The same card can use two orientations without changing its content order. Add `horizontal` to move the media to the start and stack the headline, supporting text, and action beside it. A direct `.primary-action` link can wrap those same slots when the whole horizontal card is one destination. Without a size helper, its height follows content with a 240px minimum; `small`, `medium`, and `large` provide fixed expanded heights. Below 600px, both forms return to the vertical layout and fixed heights reset to content. Inside a `.query-container` narrower than 600px they do the same on any window, so a horizontal card in a sidebar stacks too.

```html
<!-- Vertical -->
<article class="outlined">
  <img src="images/the-hideout.jpg" alt="Musician playing guitar during a live performance">
  <h3>Performances at The Hideout</h3>
  <p>Watch exclusive live performances at The Hideout every Saturday starting at 7pm.</p>
  <div class="actions">
    <button type="button" class="tonal">Get tickets</button>
  </div>
</article>

<!-- Horizontal: only the orientation class changes. -->
<article class="outlined horizontal">
  <img src="images/the-hideout.jpg" alt="Musician playing guitar during a live performance">
  <h3>Performances at The Hideout</h3>
  <p>Watch exclusive live performances at The Hideout every Saturday starting at 7pm.</p>
  <div class="actions">
    <button type="button" class="tonal">Get tickets</button>
  </div>
</article>

<!-- Directly actionable horizontal card. -->
<article class="outlined horizontal">
  <a class="primary-action" href="/performances/the-hideout">
    <img src="images/the-hideout.jpg" alt="Musician playing guitar during a live performance">
    <h3>Performances at The Hideout</h3>
    <p>Watch exclusive live performances every Saturday.</p>
  </a>
</article>
```

### Reveal

An `<aside>` expands in normal flow below the persistent media, headline, and subhead. Place a `.card-reveal-trigger` button over the media; the same button opens and closes the details. `Cards.Init()` and `AutoInit()` own `aria-expanded` when the card contains both that button and one identified direct `<aside>`, and they apply closed-panel styles only after accepting that disclosure contract. The trigger must be enabled when Cards initializes; rejected, disabled-only, unidentified, or multi-panel disclosures leave every panel visible, available, and untouched. The native button handles Enter and Space. Escape closes an open panel and returns focus to its trigger. A trigger belongs to its closest `<article>`; nested cards, including cards inside an outer reveal panel, keep their own disclosure ownership and do not initialize or toggle the outer card. The reveal grows the card instead of covering or internally scrolling it. This replaces legacy `.activator` markup and heading-based close controls; use the same native button to open and close the panel.

```html
<article class="filled">
  <figure>
    <img src="images/ana-russo.jpg" alt="Portrait of Ana Russo">
    <button type="button" class="card-reveal-trigger" aria-label="Toggle contact details" aria-controls="ana-contact"></button>
  </figure>
  <header class="card-reveal-summary">
    <h3>Ana Russo</h3>
    <p class="subhead">Sibling</p>
  </header>
  <aside id="ana-contact">
    <address class="reveal-actions">
      <a class="reveal-action" href="tel:+16505551234">
        <span class="material-symbols" aria-hidden="true">call</span>
        <span>(650) 555-1234</span>
      </a>
      <a class="reveal-action" href="mailto:hey@anarusso.com">
        <span class="material-symbols" aria-hidden="true">mail</span>
        <span>hey@anarusso.com</span>
      </a>
    </address>
  </aside>
</article>
```

### Expanding card

An expanding card performs a shared-container transition from a compact feed item into a full-screen modal detail surface. Use an `<article class="expanding-card">` with a direct `<dialog class="expanding-card-dialog">`. Keep the same hero image in both states so the media appears to grow with the container. `ExpandingCard.Init()` and `AutoInit()` wire up the modal, measured clip origin, back action, focus return, Escape, and reduced motion.

The container morph and the hero run on the slow spatial spring (`--md-comp-expanding-card-motion-duration` and `-motion-easing`), the close button and detail content on the default spatial spring (`-content-duration`, `-content-easing`), and their fade on the fast effects spring (`-fade-duration`, `-fade-easing`), so `motion="expressive"` reaches all three. Override a duration together with its easing. `--md-comp-expanding-card-motion-duration` controls the container transition. Close cleanup follows the actual clip transition, including cancellation, and runs immediately with zero duration or reduced motion. Reopening or destroying the instance invalidates pending cleanup. `onClose` runs when closing starts.

Detail content starts after `--md-comp-expanding-card-content-delay`, declared on `article.expanding-card` and backed by `--md-sys-motion-duration-short4`, which defaults to 200ms. Override the system token on the page or the component property on the article. `0ms` removes the delay. Reduced motion disables the transitions even with a nonzero delay override.

```html
<article class="outlined expanding-card">
  <figure>
    <img src="images/glass-souls.jpg" alt="Pastel balloons floating above flowers">
    <button type="button" class="expanding-card-trigger" aria-label="Open Glass Souls album" aria-haspopup="dialog"></button>
  </figure>
  <header class="expanding-card-summary">
    <h3>Listen to Glass Souls</h3>
    <p class="subhead">From your recent favorites</p>
  </header>
  <dialog id="glass-souls-card" class="expanding-card-dialog" aria-labelledby="glass-souls-title">
    <button type="button" class="expanding-card-close" aria-label="Back"><span class="material-symbols" aria-hidden="true">arrow_back</span></button>
    <figure class="expanding-card-hero">
      <img src="images/glass-souls.jpg" alt="Pastel balloons floating above flowers">
    </figure>
    <div class="expanding-card-content">
      <header class="expanding-card-detail-header">
        <h2 id="glass-souls-title">Glass Souls’ Biggest Hits</h2>
        <div class="expanding-card-actions">
          <button type="button" class="expanding-card-favorite" aria-label="Favorite album"><span class="material-symbols" aria-hidden="true">favorite</span></button>
          <button type="button" class="expanding-card-play" aria-label="Play album"><span class="material-symbols" aria-hidden="true">play_arrow</span></button>
        </div>
      </header>
      <div class="expanding-card-track">
        <strong>Fragile</strong><small>Glass Souls</small><time datetime="PT3M34S">3:34</time>
      </div>
    </div>
  </dialog>
</article>
```

### Sizes

`small`, `medium`, and `large` lock the height at 300px, 400px, and 500px so a row of cards lines up. Media takes the top 60%; the action row sticks to the bottom. These sizes are not in the M3 spec — they are optional layout helpers.

```html
<article class="small">…</article>
<article class="medium">…</article>
<article class="large">…</article>
```

---

## Aura

A light that runs around the border of the one button or card a screen most needs you to see.

An `.aura` wraps one element and runs a light around a 2px ring just outside it. Use it on the button, card or panel a screen most needs the user to notice, and keep it to one per screen. `dual` sends primary and tertiary lights around opposite sides, `rainbow` turns the whole hue wheel, and `glow` draws a blurred halo behind the element in place of a ring; give a glowing element an opaque container. The ring has 12px corners to match a card and is fully rounded around a button; set `--md-comp-aura-shape` to the corner size of anything else. Reduced motion stops the turning and keeps the ring. A shadow root ignores `@property`, so with the sheet adopted only there the light turns in 7.2deg steps. CSS only; there is no JavaScript.

The light is decoration drawn by a pseudo-element, so the wrapper adds nothing to the accessibility tree. Say in the text why the element matters, for example with a "Recommended" heading. In forced colors the ring is a solid `Highlight` outline.

```html
<div class="aura">
  <button class="filled">Upgrade</button>
</div>

<div class="aura rainbow">
  <article>
    <h3>Pro plan</h3>
    <p>Unlimited projects and priority support.</p>
  </article>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-aura-color` | `primary` |
| `--md-comp-aura-color-2` | `tertiary`; used by `dual` |
| `--md-comp-aura-width` | 2px |
| `--md-comp-aura-shape` | 12px; 9999px around a button |
| `--md-comp-aura-duration` | 4s |

---

## Carousel

A Material 3 adaptive carousel for visual collections.

The default is multi-browse: one large, one medium, and one small item adapt as the active item changes. `AutoInit()` starts every `.carousel` except those marked `no-autoinit`. Give the container an accessible name and every direct item the `carousel-item` class.

```html
<div class="carousel" aria-label="Featured landscapes">
  <a class="carousel-item" href="mountain-lake.html">
    <img src="images/mountain-lake.jpg" alt="Mountain lake">
    <span class="carousel-item-content">Mountain lake</span>
  </a>
  <a class="carousel-item" href="forest-path.html">
    <img src="images/forest-path.jpg" alt="Forest path">
    <span class="carousel-item-content">Forest path</span>
  </a>
  <a class="carousel-item" href="rocky-coastline.html">
    <img src="images/rocky-coastline.jpg" alt="Rocky coastline">
    <span class="carousel-item-content">Rocky coastline</span>
  </a>
</div>
<div class="mt-1"><a class="button text" href="all-landscapes.html">Show all</a></div>
```

The text treatment is an opaque `surface` / `on-surface` bounding shape, so its contrast does not depend on the image. Keep item text brief and avoid more than two lines at compact widths. The component uses 8dp gaps, 16dp inline padding, 8dp block padding, 28dp corners, and 40–56dp small items.

### Layouts

| Layout | Class | Use |
| --- | --- | --- |
| Multi-browse | `.carousel` | Many simple visual items; snap-scrolling. |
| Uncontained | `.carousel.uncontained` | Text-heavy or customized equal-width items; free scrolling. Add `.snap` to snap. |
| Multi-aspect uncontained | `.carousel.uncontained.multi-aspect` | Sources that genuinely range from 9:16 to 16:9. Set `--md-comp-carousel-item-aspect-ratio` per item. |
| Hero | `.carousel.hero` | One large visual and one 40–56dp preview; snap-scrolling. |
| Center-aligned hero | `.carousel.hero.center-aligned` | One centered large visual and two previews. |
| Full-screen | `.carousel.full-screen` | Vertical immersive feed in portrait compact and medium layouts only. |

```html
<div class="carousel uncontained" aria-label="Travel stories">…</div>
<div class="carousel hero" aria-label="Featured destinations">…</div>
<div class="carousel hero center-aligned" aria-label="Featured destinations">…</div>
<div class="carousel full-screen" aria-label="Featured stories">…</div>
```

Multi-browse, hero, and full-screen snap. Uncontained uses free scrolling unless `snap` is added. The rendered container width fits two, three, or four large items at medium, large, and extra-large widths, including pane-only resizes. Fine pointers can drag the track while trackpads keep native scrolling. Buttons and links inside items keep ordinary click activation; dragging does not activate them. Full-screen is vertical and edge-to-edge in portrait compact and medium layouts, then automatically adapts to a horizontal hero in landscape or at expanded widths.

On a vertically scrolling page, put a **Show all** action 4dp below every horizontal carousel. It should open a normal vertically scrolling view of the same items. If there is a heading, a 48dp arrow action may sit beside the heading instead. Do not overlay previous/next controls or place them beside the carousel edges.

Focus starts on the first item rather than the container. Left/right arrows move through horizontal items. Up/down arrows move through portrait full-screen items and otherwise leave the carousel. Home/End move to the first/last item. Reduced-motion mode removes parallax and size morphing, uses equal widths, and disables smooth scrolling.

Horizontal keys and scroll placement mirror in RTL while `next()` and `prev()` retain logical item order. Editable descendants keep their own keys. Auto-advance stays suspended during internal focus moves and responds to mounted reduced-motion changes without clearing an explicit pause. Enabling reduced motion stops in-flight smooth scrolling and realigns the active item. Native pointer and wheel gestures cancel deferred transition recentering until the next explicit navigation. Teardown cancels pending scroll-completion work.

When enabling `interval`, provide a visible application-owned pause/resume button. Call `pause()` on pause and `start()` on resume, updating the button text to describe its next action. Keep that control outside the scrolling track. Resuming still respects hover, focus, background-tab and reduced-motion suspension. The Carousel docs page includes a working example. Native zoom, screen readers, physical devices and actual Windows contrast themes remain pending manual review.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-carousel-height` | 240px compact; 320px medium; 360px expanded |
| `--md-comp-carousel-shape` | 28px |
| `--md-comp-carousel-pressed-shape` | 20px |
| `--md-comp-carousel-gap` | 8px |
| `--md-comp-carousel-inline-padding` | 16px |
| `--md-comp-carousel-block-padding` | 8px |
| `--md-comp-carousel-large-item-width` | Responsive, capped by layout |
| `--md-comp-carousel-medium-item-width` | Responsive |
| `--md-comp-carousel-small-item-min-width` | 40px |
| `--md-comp-carousel-small-item-max-width` | 56px |
| `--md-comp-carousel-uncontained-item-width` | `min(78%, 320px)` |
| `--md-comp-carousel-item-aspect-ratio` | Per item, multi-aspect only |

The older `--carousel-height` author hook is still read.

### Initialization

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.carousel');
  const instances = Expressive.Carousel.init(elems);
});
```

```js
Expressive.AutoInit(document.body, {
  Carousel: {
    i18n: { carousel: 'Galería', item: 'Elemento', of: 'de' }
  }
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `duration` | Number | `200` | Milliseconds allowed for a programmatic scroll to land, and the part of each auto-advance rest that is not `interval`. |
| `indicators` | Boolean | `false` | Legacy paging dots. M3 recommends a nearby Show all path instead of overlay controls. |
| `noWrap` | Boolean | `false` | Stop auto-advance at the last item instead of looping back to the first. Arrow keys and `set()` stop at the ends either way — a scroll track has ends. |
| `interval` | Number | `0` | Milliseconds to rest between automatic advances, on top of `duration`; a full cycle takes `duration + interval`. `0` leaves auto-advance off. Each rest is armed by the move before it rather than on a fixed phase. |
| `height` | Number | `null` | Fixed track height in pixels. `null` sizes the carousel from its content. |
| `onCycleTo` | Function | `null` | Called when the active item changes. |
| `i18n` | Object | `{ carousel: 'Carousel', item: 'Item', of: 'of', indicators: 'Slides', slide: 'Slide' }` | Generated accessible label strings. `indicators` names the indicator row and `slide` prefixes each dot, giving "Slide 1". Partial objects are merged with the defaults. |

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Carousel.getInstance(elem);
```

`next()` and `prev()` move one item or an optional item count. `set(index, callback)` moves to a zero-based item index. `pause()` and `start()` stop and resume auto-advance, and do nothing without an `interval`. `destroy()` removes generated labels, size roles, indicators, listeners, the scroll-track wrapper, and the auto-advance timer.

```js
instance.next();
instance.prev(3);
instance.set(3);
instance.pause();
instance.start();
instance.destroy();
```

An `interval` makes the carousel advance on its own, so the pause contract is
mandatory: it always pauses on hover, on focus within, and while the tab is
hidden, and `prefers-reduced-motion: reduce` suppresses auto-advance entirely.
No option disables any of that. An explicit `noWrap: true` stops auto-advance
after one pass instead of looping. Arrow keys and `set()` stop at the ends
either way — a scroll track has ends, and auto-advance is the one caller that
can loop back past them. A `height` gives the indicators their own row below the track (`.fixed-height`)
instead of laying them over the media; markup can do the same by carrying
`.fixed-height` and setting `--carousel-height`.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `center` | Number | The index of the center item. |

---

## Image compare

Two images of the same scene with a divider that reveals one over the other.

A `.image-compare` holds two `<img>` and a native `<input type="range">`. The range is the control, so keys, drags and taps work as on any slider and its value is announced; it covers the images invisibly. The `ImageCompare` behavior writes the value to `--md-comp-image-compare-position`, which clips the second image and places the divider and handle. RTL starts the first image on the right. Slider leaves this range alone. Give each image alt text that says which it is, and name the range. `Expressive.ImageCompare.sync(range)` places a compare added after load; the root bundle starts the behavior on load, and the modular entry with `ImageCompare.Init()`.

```html
<div class="image-compare">
  <img src="before.jpg" alt="Before: the faded original photo" width="800" height="500">
  <img src="after.jpg" alt="After: the restored photo" width="800" height="500">
  <input type="range" min="0" max="100" value="50" aria-label="Compare before and after">
</div>
```

Tokens: `--md-comp-image-compare-position` (50%, written by the behavior), `--md-comp-image-compare-shape` (16px), `--md-comp-image-compare-divider-color` (`surface`) and `--md-comp-image-compare-handle-size` (40px).

---

## Marquee

A row of logos, images or text that scrolls without end.

A `.marquee` holds two identical `<ul class="marquee-content">` groups. The second is a copy that fills the gap as the first scrolls out, so mark it `aria-hidden="true"` and give each link or button in it `tabindex="-1"`. Each group is at least as wide as the strip and spreads short content out, so one copy is enough. Hovering over the strip or focusing a link inside pauses it. CSS only.

```html
<div class="marquee fade">
  <ul class="marquee-content" aria-label="Customers">
    <li><img src="launchpad.svg" alt="Launchpad"></li>
    <li><a href="/customers/greenleaf"><img src="greenleaf.svg" alt="Greenleaf"></a></li>
  </ul>
  <ul class="marquee-content" aria-hidden="true">
    <li><img src="launchpad.svg" alt="Launchpad"></li>
    <li><a href="/customers/greenleaf" tabindex="-1"><img src="greenleaf.svg" alt="Greenleaf"></a></li>
  </ul>
</div>
```

- A strip scrolls toward the inline start: left, or right on a right-to-left page. `reverse` scrolls the other way.
- `vertical` scrolls up, or down with `reverse`. The strip is 320px tall unless the page sets a `height`.
- `fade` fades the items out at both edges.
- `paused` stops the strip. When it runs beside other content for more than five seconds, add a button outside the strip that toggles `paused` and its own `aria-pressed`.
- With reduced motion the strip stops, the copy is hidden and the strip scrolls by hand when its items overflow.
- Do not put information people need only in a marquee.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-marquee-duration` | 20s; the time one group takes to pass |
| `--md-comp-marquee-gap` | 32px; between items and between the two groups |
| `--md-comp-marquee-fade-size` | 48px; each edge fade with `fade` |

---

## Confetti

Bursts of theme-colored confetti for a moment worth celebrating, such as a finished order or a completed goal.

Give a button the `confetti` class and each click bursts from its center over the whole page. A `<canvas class="confetti" aria-hidden="true">` covers its nearest positioned ancestor and bursts inside it once when initialized, unless `manualStart` is set, and again on each `fire()`. `Expressive.Confetti.fire(options)` bursts over the page with no markup. `AutoInit()` starts every `.confetti`.

```html
<button type="button" class="filled confetti">Celebrate</button>

<div style="position: relative; height: 320px">
  <canvas class="confetti" aria-hidden="true"></canvas>
</div>

<script>
Expressive.Confetti.fire({ particleCount: 100, spread: 70, origin: { x: 0.5, y: 0.6 } });
</script>
```

- The page canvas is a manual popover, so it draws above an open modal dialog. It ignores the pointer and is hidden from assistive technology, and it is removed when the last piece fades.
- Without `colors`, the pieces use the theme's primary, secondary and tertiary colors and their containers, read where the burst starts.
- Nothing is drawn when the reader prefers reduced motion, and `fire()` resolves at once.
- Confetti is decoration. Say what happened in text as well, for example in a snackbar.

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `particleCount` | Number | `50` | Pieces per burst. |
| `angle` | Number | `90` | Launch direction in degrees; 90 is straight up and 0 is right. |
| `spread` | Number | `45` | Degrees the pieces fan out from the angle. |
| `startVelocity` | Number | `45` | Launch speed in pixels per frame. A small canvas needs a lower speed to keep the pieces inside it. |
| `decay` | Number | `0.9` | Share of its speed a piece keeps each frame. |
| `gravity` | Number | `1` | Pull toward the bottom; 0 floats. |
| `drift` | Number | `0` | Sideways push per frame; negative drifts left. |
| `ticks` | Number | `200` | Frames a piece lives while it fades, counted at 60 per second. |
| `origin` | Object | `{ x: 0.5, y: 0.5 }` | Launch point as fractions of the canvas from 0 to 1; a missing side is 0.5. A button launches from its own center instead. |
| `colors` | Array | `[]` | CSS colors. Empty uses the theme colors. |
| `shapes` | Array | `['square', 'circle']` | Any of `'square'`, `'circle'` and `'star'`. |
| `scalar` | Number | `1` | Piece size multiplier. |
| `manualStart` | Boolean | `false` | On a canvas, wait for `fire()` instead of bursting at init. |

### Methods

#### Confetti.fire(options)

Bursts over the whole page. Returns a promise that resolves when the last piece is gone.

#### .fire(options)

Bursts on this canvas, or from this button's center over the page, with these options merged over the instance's. Returns the same kind of promise.

#### .destroy()

Removes a button's click handler, or stops and clears a canvas.

---

## Particles

Drifting dots behind a section, such as a hero or a sign-in panel, that lean toward the pointer.

A `<canvas class="particles" aria-hidden="true">` covers its nearest positioned ancestor and lets clicks through. Each dot drifts slowly, fades out near the edges and moves toward the pointer while it is over the canvas. A dot that leaves the canvas is replaced inside it. `AutoInit()` starts every `canvas.particles`.

```html
<div style="position: relative; height: 320px">
  <h2>Particles</h2>
  <canvas class="particles" aria-hidden="true"></canvas>
</div>

<script>
Expressive.Particles.init(document.querySelector('canvas.particles'), { quantity: 150, vy: -0.3, staticity: 20 });
</script>
```

- The dots take the canvas's CSS `color`, which is `--md-sys-color-on-surface` unless the page sets another. The `color` option wins over the stylesheet.
- The canvas refits and scatters a new set of dots when its box changes size.
- Under reduced motion the dots are drawn once and stay still.
- Particles are decoration. Keep the content above them readable.

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `quantity` | Number | `100` | Number of dots. |
| `staticity` | Number | `50` | How firmly dots hold their place against the pointer; lower follows further. |
| `ease` | Number | `50` | Frames a dot takes to catch up with the pointer; higher is slower. |
| `size` | Number | `0.4` | Base radius in pixels. Each dot adds 0 or 1. |
| `color` | String | `''` | A CSS color. Empty uses the canvas's CSS `color`. |
| `vx` | Number | `0` | Horizontal drift in pixels per frame; negative drifts left. |
| `vy` | Number | `0` | Vertical drift in pixels per frame; negative drifts up. |

### Methods

#### .destroy()

Stops drawing, removes the pointer listener and clears the canvas.

---

## Map

An interactive map with markers, popups, routes, arcs, clusters and controls, built on MapLibre.

A `.map` holds `.map-marker`, `.map-popup` and `.map-controls` children. ExpressiveCSS does not bundle MapLibre. Install `maplibre-gl`, load `maplibre-gl/dist/maplibre-gl.css` before ExpressiveCSS, and pass the module to `MapView.init()`. `AutoInit()` does not start the map. The class is `MapView` so importing it does not hide JavaScript's `Map`. Until the script runs, markers, popups and controls are hidden.

The default style is OpenFreeMap's Positron in the light theme and its Dark style in the dark theme; neither needs an API key. The map swaps style when the `theme` attribute or the system color scheme changes. Pass `style` as a URL or object to keep one style, or `{ light, dark }`. Keep the provider's attribution visible.

- A `.map-marker` sits at its `data-lng-lat`, longitude first. A value that is not two numbers with a latitude between -90 and 90 is skipped with a console warning. With no content of its own it is a primary dot; content inside replaces the dot. A `.map-marker-label` shows below it (`.top`: above), and a child `.tooltip` shows on hover and focus.
- A `<button class="map-marker">` with `aria-controls` naming a `.map-popup` opens it and sets `aria-expanded`. A map click or Escape closes it and focus returns to the marker. A `.map-popup` with its own `data-lng-lat` and no marker opens at init.
- `data-draggable` lets the reader drag a marker. When the drag ends, `data-lng-lat` holds the new position and the marker fires `change`.
- `.map-controls` stacks icon buttons in the top right, or `.top-left`, `.bottom-left`, `.bottom-right`; `.map-control-group` joins buttons on one surface. `data-action` is `zoom-in`, `zoom-out` (disabled at the zoom limits), `compass` (resets north; the icon follows the bearing), `locate` (centers on the reader and marks the spot) or `fullscreen` (sets `aria-pressed`; hidden where unsupported).
- Routes, arcs and clusters are data, added with methods. Lines and clusters take theme colors where the map is and are redrawn in the new colors when the theme changes. The map does not plan routes.
- Name the map with `role="region"` and `aria-label`, and give what matters, such as addresses, in text too.

```html
<div class="map" role="region" aria-label="Chicago stores">
  <button type="button" class="map-marker" data-lng-lat="-87.6278,41.8820"
          aria-label="Loop store" aria-controls="loop">
    <span class="map-marker-label">Loop</span>
  </button>
  <div class="map-popup" id="loop">
    <h3>Loop store</h3>
    <p>Open until 9 p.m.</p>
  </div>
  <div class="map-controls">
    <div class="map-control-group">
      <button type="button" class="icon-button" data-action="zoom-in" aria-label="Zoom in">
        <span class="material-symbols" aria-hidden="true">add</span>
      </button>
      <button type="button" class="icon-button" data-action="zoom-out" aria-label="Zoom out">
        <span class="material-symbols" aria-hidden="true">remove</span>
      </button>
    </div>
  </div>
</div>
```

```js
import 'maplibre-gl/dist/maplibre-gl.css';
import * as maplibregl from 'maplibre-gl';
import { MapView } from '@expressivecss/expressive';

const map = MapView.init(document.querySelector('.map'), { maplibregl, center: [-87.65, 41.89], zoom: 11 });
map.addRoute('delivery', [[-87.70, 41.85], [-87.62, 41.88]]);
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `maplibregl` | Module | `null` | The `maplibre-gl` module. Required. |
| `style` | String, Object | OpenFreeMap Positron and Dark | A style URL or object, or `{ light, dark }` to follow the theme. |
| `center` | Array | `[0, 20]` | Starting `[longitude, latitude]`. |
| `zoom` | Number | `1` | Starting zoom level. |
| `mapOptions` | Object | `{}` | More MapLibre `Map` options, such as `maxZoom` or `bearing`. |

### Methods

#### .addRoute(id, coordinates, options)

Draws a line through `[longitude, latitude]` points in the route color, 4px wide. Options: `color`, `width`, `opacity`, `dashArray`. Replaces an overlay with the same id.

#### .addArcs(id, arcs, options)

Draws a curve for each `{ from, to }` in the arc color, 2px wide, with the same options.

#### .addClusters(id, data, options)

Draws GeoJSON points, or a GeoJSON URL, as clusters that show their count when the style has fonts and zoom in when clicked. Options: `color`, `radius` (50), `maxZoom` (14). The layers are `${id}-clusters`, `${id}-count` and `${id}-points`.

#### .removeOverlay(id)

Removes a route, arcs or clusters.

#### .map and .markers

The MapLibre map and markers, for anything else.

#### .destroy()

Removes the map and puts the authored children back.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-map-height` | 400px |
| `--md-comp-map-shape` | 16px |
| `--md-comp-map-marker-color` | primary |
| `--md-comp-map-marker-ring-color` | surface |
| `--md-comp-map-route-color` | primary |
| `--md-comp-map-arc-color` | tertiary |
| `--md-comp-map-cluster-color` | primary |
| `--md-comp-map-cluster-label-color` | on-primary |
| `--md-comp-map-popup-max-width` | 280px |
| `--md-comp-map-control-offset` | 12px |

---

## Drag handle

The bar that makes something legible as draggable: a bottom sheet's grabber, the gutter between two panes, the grip on a reorderable row.

Which element you write *is* the semantic. A `<span class="drag-handle" aria-hidden="true">` is decoration and is the usual case — the handle draws a bar and contains no text, so exposed it arrives in the reading order as an unlabelled blank. A `<button class="drag-handle">` with an `aria-label` is a control, written only when activating it does something — inside a bottom sheet it always does, and everywhere else that is the page's to arrange. Never `aria-hidden` a button: that hides it from assistive technology without taking it out of the tab order. The hover, focus and pressed states are scoped to the button spelling, so a decorative handle does not light up under a passing pointer.

**Nothing here drags on its own.** Four places wire it: the bottom sheet below, the pane layouts, where a `<button class="drag-handle">` between two panes resizes them (see Panes, "Resizing"), data tables, where one ending a header cell resizes its column (see Data table, "Column resize"), and sortable lists, where a decorative handle drags its row and Move up and Move down buttons cover keyboard and single-pointer use (see Lists, "Sortable"). Anywhere else the page supplies reordering. Provide both keyboard operation and a single-pointer alternative without dragging, such as clickable "Move up" / "Move down" buttons or a position input. Arrow keys alone cover only the keyboard path. [WCAG 2.2 SC 2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) requires the pointer alternative unless dragging is essential or the unmodified user agent owns the functionality. Verify the alternatives independently; the handle does not implement either one.

The two-column layout is the page's own — a flex or grid row, or a pane layout. The handle is what sits between the columns and needs no wrapper class of its own.

```html
<section aria-label="Editor">…</section>
<span class="drag-handle" aria-hidden="true"></span>
<section aria-label="Preview">…</section>
```

Material tokenised the *vertical* handle — a 4×48dp bar in a 24dp hit target, `outline`, swelling to 12×52dp on a 12dp corner in `on-surface` while held. That is what `.drag-handle` draws. The container is sized to the pressed bar, so holding it moves nothing beside it. `cursor: grab` is the default; a splitter that resizes reads better as `cursor: col-resize`, which is one declaration of your own.

On a bottom sheet the same class takes the sheet's grabber instead — a horizontal 32×4dp bar at 40% `on-surface-variant`, which is Material's own separate size and colour for that slot. A decorative handle needs a separate non-drag dismiss path. A named `<button>` handle closes the sheet by click, tap, <kbd>Enter</kbd>, or <kbd>Space</kbd>; a `form method="dialog"` close button is another option. Native Escape behavior depends on the opening mode, browser, and `closedby` policy, and is not a pointer alternative. Scrim dismissal also depends on the installed shared runtime and `closedby` policy; test it before relying on it. A drag that snaps back does not also activate the button because the runtime distinguishes the ending click from a tap. Panes and sortable lists wire handles too, as described above.

```html
<dialog class="bottom-sheet" aria-labelledby="sheet-title">
  <button type="button" class="drag-handle" aria-label="Dismiss"></button>
  <h2 id="sheet-title">Share</h2>
</dialog>
```

Tokens are `--md-comp-drag-handle-{container-width,width,height,shape,color}` and the `pressed-*` and `state-layer-color` set. Inside a bottom sheet none of them apply; `--md-comp-bottom-sheet-drag-handle-color` does.

---

## Lists

Continuous, vertical indexes of text and images. Use a list so people can find an item and act on it. The HTML is the component; the script only adds arrow keys to lists of link or button rows.

The leading visual is the first icon, `<img>`, or `<input>`. The trailing action is the last icon, `<kbd>`, `<button>`, `<time>`, or `.meta`. A following `<p>` is supporting text. Mark the current row with `active` or `selected` — **not** `aria-selected`, which is not valid on a plain `listitem` and needs a role (`option`, `tab`, `row`, `treeitem`) that brings a keyboard contract with it.

Two variants: standard (the default) and `segmented`.

### Standard

Transparent rows. The selected item is a pill in `secondary-container`. The leading icon fills when the row is selected.

```html
<ul class="list">
  <li>
    <span class="material-symbols" aria-hidden="true">star</span>
    List item
    <kbd>⌘C</kbd>
  </li>
  <li class="selected">
    <span class="material-symbols" aria-hidden="true">star</span>
    List item
    <kbd>⌘C</kbd>
  </li>
</ul>
```

### Segmented

Add `segmented`. Every row is a rounded tile; the selected tile uses the same `secondary-container` fill.

```html
<ul class="list segmented">
  <li class="selected">
    <span class="material-symbols" aria-hidden="true">star</span>
    List item
    <kbd>⌘C</kbd>
  </li>
</ul>
```

### Text

A row can be a single line of label text, or a label with supporting text underneath. Either can wrap, or take `truncate` to ellipsis.

```html
<li>Label text only</li>
<li><span class="truncate">A long label…</span></li>
<li>
  Headline
  <p>Supporting text sits under the label.</p>
</li>
```

### Icons

A leading icon is a quick visual cue for the label. A trailing icon is status or an action.

```html
<li>
  <span class="material-symbols" aria-hidden="true">inbox</span>
  Inbox
  <span class="material-symbols" aria-hidden="true">chevron_right</span>
</li>
```

### Links

Wrap the row in an `<a>` or `<label>` to make the whole item the target. `aria-current` on the link, or a checked radio inside the label, paints the selected pill.

In a list whose rows are each a single link or button, Up and Down move focus to the row before or after, and Home and End to the first and last, skipping disabled rows and stopping at the ends. Every row stays a Tab stop, so the keys are a shortcut that needs the script; rows with more than one control, and radio rows, which the browser already moves with the arrows, keep their native keys.

```html
<li>
  <a href="/inbox" aria-current="page">
    <span class="material-symbols" aria-hidden="true">inbox</span>
    Inbox
  </a>
</li>
```


### Sortable

Add `sortable` to a list to let people reorder it. Drag an item by its decorative `.drag-handle`, or press its `[data-move="up"]` and `[data-move="down"]` buttons; every item needs both, in the row (a `.sortable-actions` group) or in a menu, because they are the keyboard path and the single-pointer alternative to dragging. Escape during a drag puts the item back. After each move the list fires a bubbling `change` event whose `detail` is `{ item, from, to }`, and a polite live region announces the position (`Expressive.Sortable.i18n.moved`). `Expressive.Sortable.move(item, index)` works from script. The root bundle starts the behavior on load; with the modular entry, call `Sortable.Init()`.

```html
<ul class="list sortable" aria-label="Playlist">
  <li>
    <span class="drag-handle" aria-hidden="true"></span>
    <span>Blue in Green</span>
    <span class="sortable-actions">
      <button type="button" class="icon-button" data-move="up" aria-label="Move Blue in Green up">
        <span class="material-symbols" aria-hidden="true">arrow_upward</span>
      </button>
      <button type="button" class="icon-button" data-move="down" aria-label="Move Blue in Green down">
        <span class="material-symbols" aria-hidden="true">arrow_downward</span>
      </button>
    </span>
  </li>
</ul>
```

### Long lists

Add `defer-render` to a long list so the browser skips layout and paint for rows that are off screen. Each direct child gets `content-visibility: auto`; it works on any container whose children stack, such as a column of cards. Off-screen rows stay in the DOM, so screen readers, find-in-page and scripts still reach them. A skipped row reserves `--defer-render-row-size`, which defaults to `--md-comp-list-item-height` inside a `.list` and to 3.5rem elsewhere; the browser remembers a row's real height once it has been on screen. Set the property to the average row height when rows differ, so the scrollbar stays close to the list's real length.

```html
<ul class="list defer-render" aria-label="Invoices" style="--defer-render-row-size: 4.5rem">
  <li>…</li>
</ul>
```

The browser clips anything a deferred row draws outside its box. Focus rings inside a row are drawn inset for that reason, but a box shadow or a badge that hangs over the edge is cut off. Each row is also the containing block for absolutely and fixed positioned content; menus and tooltips open in the top layer and are not affected. Rows with `.swipe-actions` are never deferred, because their actions wait past the row's end. Tables are not supported, because browsers ignore `content-visibility` on table rows. When the rows are not in the DOM at all, such as a dataset too large to render as markup or one fetched a page at a time during scrolling, use a virtual list script; ExpressiveCSS does not ship one.

---

## Message

One message in a conversation, with an optional avatar, header and footer.

A `.message` lays out one message. It places an optional `.message-avatar`, `.message-header` and `.message-footer` around a single surface, usually a `.message-bubble`. Every other child is the surface, so wrap several elements in one `<div>` to keep them together. Messages sit on the start side; add `end` for the sender's own messages, which also turns the bubble to `primary-container`. Write the parts as `<div>`: outside sectioning content a `<header>` is a banner landmark and a `<footer>` is contentinfo.

The avatar is an `<img>`, or a `<span>` holding initials or an icon. It lines up with the bottom of the surface and stays clear of the footer. Give the image an empty `alt` when a header already names the sender. Put `role="log"` on a conversation that receives new messages so screen readers announce them. CSS only; there is no JavaScript.

```html
<div role="log" aria-label="Conversation with Oliver">
  <div class="message end">
    <p class="message-bubble">Deploying to prod real quick.</p>
    <div class="message-footer">Delivered</div>
  </div>
  <div class="message">
    <img class="message-avatar" src="oliver.jpg" alt="">
    <div class="message-header">Oliver</div>
    <p class="message-bubble">It's 4:55 PM. On a Friday.</p>
  </div>
</div>
```

### Groups

`.message-group` stacks consecutive messages from one sender and joins their bubbles on the sender's side. Give the earlier messages an empty `.message-avatar` so they line up with the avatar on the last one.

```html
<div class="message-group">
  <div class="message">
    <span class="message-avatar"></span>
    <p class="message-bubble">I checked the registry addresses.</p>
  </div>
  <div class="message">
    <span class="message-avatar" role="img" aria-label="Oliver">OL</span>
    <p class="message-bubble">The component and example JSON now live under the UI registry.</p>
  </div>
</div>
```

### Footer actions

Copy, feedback and retry actions go in the footer as named icon buttons. On an `end` message they stay on the end side.

```html
<div class="message end">
  <p class="message-bubble">Okay drop me a link.</p>
  <div class="message-footer">
    <span class="error-text">Failed to send</span>
    <button class="icon-button xsmall" aria-label="Retry">
      <span class="material-symbols" aria-hidden="true">refresh</span>
    </button>
  </div>
</div>
```

### Attachments

`.message-bubble.outlined` has no fill and an `outline-variant` border. A bubble whose only child is an image or video frames it with a 4px inset.

```html
<div class="message">
  <div class="message-bubble outlined flex align-center gap-3">
    <span class="material-symbols" aria-hidden="true">description</span>
    <div class="min-w-0">
      <div class="title-small truncate">sales-dashboard.pdf</div>
      <div class="body-small">PDF · 2.4 MB</div>
    </div>
    <a class="icon-button" href="sales-dashboard.pdf" download aria-label="Download sales-dashboard.pdf">
      <span class="material-symbols" aria-hidden="true">download</span>
    </a>
  </div>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-message-avatar-size` | 32px |
| `--md-comp-message-gap` | 8px |
| `--md-comp-message-surface-max-width` | 80% |
| `--md-comp-message-bubble-shape` | 20px |
| `--md-comp-message-bubble-grouped-shape` | 4px |
| `--md-comp-message-bubble-container-color` | `surface-container-high`; `end`: `primary-container` |
| `--md-comp-message-bubble-color` | `on-surface`; `end`: `on-primary-container` |
| `--md-comp-message-group-gap` | 2px (on `.message-group`) |

---

## Message scroller

A chat scroll container that anchors turns, follows streamed replies and keeps its place as history loads.

A `.message-scroller` frame holds a `.message-scroller-viewport` and an optional `.message-scroller-button`. Every child of the viewport is one item, usually a `.message` or `.message-group`. The frame fills its parent, so the parent sets the height. `AutoInit()` starts every `.message-scroller`.

While the reader is at the end, the viewport follows new content, including text streamed into the last message. Scrolling toward the start with the wheel, touch, the scrollbar or the Up, Page Up and Home keys stops following; reaching the end again, or pressing the button, resumes it. Mark the message that starts a turn with `data-scroll-anchor`, usually each of the user's messages. When an anchor is appended, the viewport scrolls it to the top with 64px of the previous message in view, and blank space below it makes room for the reply until the reply fills the viewport. Older messages inserted at the start keep the visible message in place, and anchors among them do not scroll.

Name the viewport with `aria-label` and give it `tabindex="0"` so the keyboard can scroll it. On a conversation that receives messages, make the viewport the log with `role="log"` and `aria-relevant="additions"`, and set `aria-busy="true"` on it while a reply streams. The button is inert while the viewport is at the end; give it an `aria-label`.

```html
<div class="message-scroller">
  <div class="message-scroller-viewport" role="log" aria-label="Conversation"
       aria-relevant="additions" tabindex="0">
    <div id="m1" class="message end" data-scroll-anchor>
      <p class="message-bubble">Can you check why the install fails on CI?</p>
    </div>
    <div id="m2" class="message">
      <p class="message-bubble">Sure. Which package manager does the workspace use?</p>
    </div>
  </div>
  <button type="button" class="icon-button tonal message-scroller-button"
          aria-label="Scroll to latest message">
    <span class="material-symbols" aria-hidden="true">arrow_downward</span>
  </button>
</div>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `autoScroll` | Boolean | `true` | If true, the viewport follows new content while the reader is at the end, and scrolls a newly appended anchor to the top. |
| `defaultScrollPosition` | String | `'end'` | Where the transcript opens: `'end'`, `'start'`, or `'last-anchor'`, the start of the last turn. `'last-anchor'` opens at the end when that turn fits. A hidden viewport opens when it is first shown. |
| `peek` | Number | `64` | Pixels of the previous message left in view above an anchored turn or a message reached with `scrollToMessage()`. |

### Methods

#### .scrollToEnd()

Scrolls to the latest message and resumes following.

#### .scrollToStart()

Scrolls to the first message.

#### .scrollToMessage(id)

Scrolls the element with this id to the top of the viewport, below the peek, and stops following. Returns `false` when the viewport holds no such element.

#### .destroy()

Removes the listeners, observers and spacer the component added.

Scrolling is smooth unless the reader prefers reduced motion.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-message-scroller-padding` | 16px |
| `--md-comp-message-scroller-gap` | 16px (between items) |

---

## Pull-to-refresh

Refreshes a list or page when a person pulls down at its top, or presses its refresh button.

A `.pull-to-refresh` container holds a `.loading-indicator` as a direct child, a `<button data-refresh>` and the content. `AutoInit()` starts every `.pull-to-refresh`. The container can scroll itself, with `overflow-y: auto` or `scroll`, or sit in a page that scrolls; either way a pull counts only at a scroll position of 0.

A touch pull from the top brings the indicator down with the finger and fades it in. Releasing past the threshold starts a refresh. Releasing short of it, a cancelled touch, a drag that moves up or sideways first, and a second finger do nothing, and a mouse drag never pulls. A drag that a scrolled-down scroller can take, whether inside the container or around it, scrolls that scroller instead. The refresh button starts the same refresh, so keyboard, mouse and switch users can refresh without a touch screen.

A refresh fires `expressive:refresh` from the container and runs `onRefresh`. Both get a `detail` with `waitUntil(promise)`. The indicator stays, and the container has `aria-busy="true"`, until every promise passed to `waitUntil()` settles, fulfilled or rejected, or the page calls `complete()`. Without either, the refresh keeps running. Pulls, button presses and `refresh()` calls during a refresh do nothing. A rejected promise still ends the refresh, and a rejection the page does not catch reaches `unhandledrejection` as usual. An `onRefresh` that throws ends the refresh it started, and the error still reaches the page.

```js
document.addEventListener('expressive:refresh', (event) => {
  event.detail.waitUntil(loadLatest());
});
```

The container has `overscroll-behavior-y: contain`, and so does the page while it holds one, so the browser's own pull-to-refresh does not run as well. Don't nest one `.pull-to-refresh` inside another. The indicator carries the role and name, as every loading indicator does; the script shows it. The script also adds a visually hidden `.pull-to-refresh-status` region with `role="status"` that announces "Refreshing" when a refresh starts and "Refreshed" when it finishes; set the words with `i18n`. Under reduced motion it hides at once instead of sliding back up.

```html
<div class="pull-to-refresh" style="height: 24rem; overflow-y: auto">
  <span class="loading-indicator contained" role="status" aria-label="Refreshing"></span>
  <button type="button" class="text" data-refresh>Refresh</button>
  <ul class="list">
    <li>Inbox</li>
  </ul>
</div>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `threshold` | Number | `64` | How far a finger must pull down, in pixels, for the release to refresh. A value that is not a positive number falls back to `64`. |
| `onRefresh` | Function | `null` | Called when a refresh starts, with the event's `detail`. |
| `i18n` | Object | `{ refreshing: 'Refreshing', refreshed: 'Refreshed' }` | What the status region announces when a refresh starts and when it finishes. |

### Methods

#### .refresh()

Starts a refresh, as a pull or the refresh button does. Does nothing while one runs.

#### .complete()

Ends the running refresh: the indicator goes back up and `aria-busy` is removed.

#### .destroy()

Removes the listeners and the status region, and ends a running refresh without an event.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-pull-to-refresh-indicator-offset` | 16px (where the indicator rests while refreshing) |
| `--md-comp-pull-to-refresh-motion` | default spatial spring |
| `--md-comp-pull-to-refresh-motion-duration` | the default spatial spring's duration |

---

## Loading overlay

Covers part of a page while it loads, so nothing inside takes input until the page clears it.

A `.loading-overlay` region holds a `.loading-indicator` as a direct child and the content. `AutoInit()` starts every `.loading-overlay`. The page sets `aria-busy="true"` on the region when its work starts, and removes the attribute, or sets any other value, when the work is done; there is no method for either. While the region is busy, a scrim covers it, the indicator shows in its centre, and the script makes the region `inert`, so nothing inside takes a click, a tap or keyboard focus. When it clears, the region gets back the `inert` state it had before.

```js
const region = document.querySelector('.loading-overlay');
region.setAttribute('aria-busy', 'true');
try {
  await save();
} finally {
  region.removeAttribute('aria-busy');
}
```

Inert content leaves the accessibility tree, the indicator's status included. The indicator keeps the role and name every loading indicator has, and the script adds a visually hidden `.loading-overlay-status` region with `role="status"` just after the region, which announces "Loading" when the region becomes busy and "Loaded" when it clears; set the words with `i18n`. The script takes focus from a control inside the region when it becomes busy, including one in a shadow root, and gives it back when the region clears, unless the person moved focus elsewhere meanwhile or the page removed the control. A region busy from the start announces "Loading" a frame after the status region is added, since screen readers often skip text a live region already holds.

The scrim and indicator show from the attribute alone, before the script starts and after `destroy()`, but only the script blocks input. They sit at z-index 5 and 6, above anything a component stacks inside itself; content the page raises higher shows through. They fade in and out, and appear at once under reduced motion. In forced colors the scrim dims the content toward `Canvas`. The scrim scrolls with the content of a region that scrolls itself, so put the overlay on a wrapper around a scroller. The region gets `position: relative` unless a utility positions it.

```html
<form class="loading-overlay">
  <span class="loading-indicator contained" role="status" aria-label="Saving"></span>
  <div class="field">
    <input id="name" type="text" placeholder=" ">
    <label for="name">Name</label>
  </div>
  <button type="submit" class="filled">Save</button>
</form>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `i18n` | Object | `{ loading: 'Loading', loaded: 'Loaded' }` | What the status region announces when the region becomes busy and when it clears. |

### Methods

#### .destroy()

Stops following `aria-busy`, removes the status region, and gives the region back the `inert` state it had before. The scrim still follows the attribute, so remove `aria-busy` too if the region should look available.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-loading-overlay-scrim-color` | surface at 70% |
| `--md-comp-loading-overlay-motion` | default effects spring |
| `--md-comp-loading-overlay-motion-duration` | the default effects spring's duration |

---

## Swipe actions

Reveals a list row's actions when a person swipes it sideways, or presses its actions button.

Add `swipeable` to a `.list`. A row with actions holds a `<button data-swipe-toggle>` as its trailing action, then a `.swipe-actions` group of buttons as its last child. `AutoInit()` starts every `.list.swipeable`.

A touch swipe toward the row's inline start, which is left in a left-to-right page, slides the row with the finger and brings the actions in from the other edge. Released past half the actions' width, or with a flick, the row stays open; released short of that, it slides back. A swipe only reveals the actions, and none of them runs until a person presses it. A swipe the other way closes an open row. A drag that moves up or down first scrolls as usual, a second finger or a cancelled touch puts the row back, and a mouse drag never swipes.

The toggle opens and closes the row for keyboard, mouse and switch users, and the script keeps its `aria-expanded` in step. The actions stay in the tab order and the accessibility tree while the row is closed, and focus on one opens its row. One row in a list is open at a time. A tap or focus outside the open row closes it, and so does Escape inside it. Escape on an action, or pressing one, closes the row and moves focus from the action to the toggle. The page handles what each action does.

Opening or closing a row fires `expressive:swipetoggle` from the list and runs `onToggle`, both with `{ row, open }`. Under reduced motion the row still follows the finger but opens and closes without sliding.

```html
<ul class="list swipeable" aria-label="Inbox">
  <li>
    <span class="material-symbols" aria-hidden="true">mail</span>
    Weekly report
    <button type="button" class="icon-button" data-swipe-toggle aria-label="Actions for Weekly report">
      <span class="material-symbols" aria-hidden="true">more_horiz</span>
    </button>
    <span class="swipe-actions">
      <button type="button" class="icon-button" aria-label="Archive Weekly report">
        <span class="material-symbols" aria-hidden="true">archive</span>
      </button>
      <button type="button" class="icon-button" aria-label="Delete Weekly report">
        <span class="material-symbols" aria-hidden="true">delete</span>
      </button>
    </span>
  </li>
</ul>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `onToggle` | Function | `null` | Called when a row opens or closes, with `{ row, open }`. |

### Methods

#### .open(row)

Opens a row of the list, closing the one that was open.

#### .close()

Closes the open row, if there is one.

#### .destroy()

Removes the listeners, closes the open row without an event and removes the toggles' `aria-expanded`.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-swipe-actions-gap` | 4px (between the actions) |
| `--md-comp-swipe-actions-motion` | default spatial spring |
| `--md-comp-swipe-actions-motion-duration` | the default spatial spring's duration |

---

## Accordion

Stacked disclosures that open one section of content at a time, on native details.

A `.accordion` holds `<details>` items. An item's `<summary>` is its header, and everything after the summary is its panel. The browser owns the behavior: the summary is a button that reports its expanded state, Enter and Space toggle it, and `open` sets the initial state. Give the items one shared `name` to make them exclusive, so opening one closes the others. CSS only; there is no JavaScript.

The items join as tiles. Outer corners take the full shape and inner corners the grouped shape, and an open item rounds every corner. Opening animates in engines that can transition to `auto` height, and opens at once elsewhere or under reduced motion. Keep links and buttons out of the summary, which is already a button; put actions in the panel.

```html
<div class="accordion">
  <details name="faq" open>
    <summary>Can I change my plan later?</summary>
    <p>Yes. The new plan starts with your next billing period.</p>
  </details>
  <details name="faq">
    <summary>How do I export my data?</summary>
    <p>Open Settings, then Export. The download includes every project you own.</p>
  </details>
</div>
```

### Leading icon

A leading icon goes first in the summary, hidden from assistive technology. The summary text names the item.

```html
<div class="accordion">
  <details>
    <summary>
      <span class="material-symbols" aria-hidden="true">lock</span>
      Privacy
    </summary>
    <p>Choose who can see your profile and activity.</p>
  </details>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-accordion-container-color` | `surface-container` |
| `--md-comp-accordion-shape` | 20px |
| `--md-comp-accordion-grouped-shape` | 4px |
| `--md-comp-accordion-gap` | 2px |
| `--md-comp-accordion-header-height` | 56px |
| `--md-comp-accordion-padding` | 16px |

---

## Show more

Clamped text with a button that shows the rest, and hides itself when the text already fits.

A `.show-more` holds the text, with a `line-clamp-1`, `line-clamp-2` or `line-clamp-3` class, and a `<button type="button">` whose text is the collapsed label. `AutoInit()` starts every one. The component points the button at the text with `aria-controls`, adding an id to the text when it has none, and sets `aria-expanded`. Activating the button shows the full text and changes the label to "Show less"; activating it again clamps the text and puts the author's label back. Only the button's last text outside an `aria-hidden="true"` element changes, so an icon keeps its glyph. An icon-only button named by `aria-label` has no text to change: it keeps its name, and only `aria-expanded` says the text is open. Prefer a visible label.

While the clamped text fits, the button gets `hidden`, so it is neither seen nor reached by Tab. The component checks again when the text's size changes and once web fonts have loaded, so `line-clamp-3 expanded:line-clamp-none` hides the button from Expanded windows up. A focused button stays until focus moves on, and open text keeps its button. Expanding sets `-webkit-line-clamp` and `line-clamp` inline, because a prefixed clamp class would beat `line-clamp-none`. The text opens and closes at once, without a transition.

The clamp classes set `display`, so the text cannot also be a flex or grid container; put the clamp on a child. The browser's find-in-page does not open clamped text, so a match in the hidden lines is not shown until the person opens it.

```html
<div class="show-more">
  <p class="line-clamp-3">A long product description that runs well past three lines...</p>
  <button type="button" class="text">Show more</button>
</div>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `onOpen` | Function | `null` | Called after the full text shows. |
| `onClose` | Function | `null` | Called after the text is clamped again. |
| `i18n` | Object | `{ showLess: 'Show less' }` | The button's label while the text is open. `data-show-less` on the button sets it for one control. |

### Methods

#### .open()

Shows the full text.

#### .close()

Clamps the text again.

#### .destroy()

Removes the listeners, clamps the text without an event, and restores the button's label, `aria-expanded`, `aria-controls` and `hidden`, and the text's id.

---

## Data table

Rows of records in a scrolling table with a sticky header, sort state and row selection.

A `.data-table` is a `<div>` around one native `<table>`. The wrapper scrolls and draws the outline, so the table keeps its table semantics. The header row sticks to the top of the wrapper once the wrapper scrolls vertically, so give the wrapper a `max-block-size` for long tables. Add `numeric` to the header and body cells of a number column to align them on the end edge with tabular figures, and `dense` to the wrapper for shorter rows.

Put a `<button>` in each sortable `<th>` and set `aria-sort="ascending"` or `"descending"` on the header of the sorted column. The framework draws the arrow. Add `data-sort` to the wrapper and the framework sorts too: a header button reorders the rows by its column and moves `aria-sort`. A `numeric` header compares numbers (`$1,200.00` after `$87.00`); other columns compare text in the page's language, with 9 before 10. A cell's `data-value` stands in for its text, and a `tr.data-table-detail` moves with its row. Each press fires a cancelable `expressive:sort` from the wrapper with `{ header, column, direction }`; a server-paged table cancels it and loads the sorted page, with `aria-sort` already moved. Without `data-sort`, the page reorders the rows and moves `aria-sort` itself.

Add a labelled `input[type="search"][data-table-filter]` inside the wrapper for text filtering, and put `data-filter` on each searchable single-column header. A named `output[data-table-filter-count]` with `role="status"` and `aria-live="polite"` receives the matching record count. Filtering keeps record details together and preserves author-hidden rows. Select-all changes only enabled records visible in the current view, while hidden records keep their selections. Each edit fires cancelable `expressive:filter` with `{ query }`. Cancel synchronously for server filtering, then replace records, write the count and call `Expressive.DataTables.sync(wrapper)`. Sync stays silent and preserves server records and counts until the next uncanceled input; otherwise it reapplies the local query.

Opt in to local paging with a positive safe-integer `data-page-size` on the wrapper. Authored Previous/Next `button[type="button"][data-table-page]` controls in a named `nav.pagination` move between filtered, sorted record groups. An optional named `output[data-table-page-status][role="status"][aria-live="polite"]` shows page numbers. Selection is page-scoped and retained across pages; silent `sync(wrapper)` clamps the current page after record changes. Ordinary pagination links keep their navigation behavior.

Add a labelled single `select[data-table-page-size]` to change the size through a native select or FormSelect. A valid value updates the wrapper and preserves or clamps the current page, retaining selections and counts. Disabled controls, unavailable options and invalid values leave the working size unchanged.

```html
<div class="data-table" data-sort>
  <table>
    <caption>Orders</caption>
    <thead>
      <tr>
        <th scope="col" aria-sort="ascending"><button type="button">Customer</button></th>
        <th scope="col">Status</th>
        <th scope="col" class="numeric"><button type="button">Total</button></th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Alvin</th>
        <td>Shipped</td>
        <td class="numeric">$87.00</td>
      </tr>
      <tr>
        <th scope="row">Shannon</th>
        <td>Pending</td>
        <td class="numeric">$9.99</td>
      </tr>
    </tbody>
  </table>
</div>
```

### Row selection

A checked checkbox in a row's first cell selects the row and fills it with `secondary-container`. Name each checkbox after its row. `aria-selected` is not valid on the rows of a plain table, so the checkbox carries the state. A checkbox in the first header cell selects every enabled record visible in the current view, and shows mixed while only some of those records are selected. Hidden records keep their selections. Each change fires `expressive:change` from the wrapper with `{ selected }`, all selected records, including hidden ones. Checking rows from script fires no change, so call `Expressive.DataTables.sync(wrapper)` afterwards, and for a table rendered after the page loaded with rows already checked. `sync` also applies the table's column toggles and current filter.

```html
<div class="data-table">
  <table>
    <thead>
      <tr>
        <th scope="col"><label><input type="checkbox" aria-label="Select all orders"></label></th>
        <th scope="col">Customer</th>
        <th scope="col" class="numeric">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><label><input type="checkbox" checked aria-label="Select Alvin"></label></td>
        <th scope="row">Alvin</th>
        <td class="numeric">$87.00</td>
      </tr>
    </tbody>
  </table>
</div>
```

### Text filtering

Put a labelled `input[type="search"][data-table-filter]` inside the wrapper and mark each searchable column's `<th>` with `data-filter`. Mark only headers that span one column, including headers in grouped rows. The trimmed query matches a substring in any marked column, ignoring case in the table's language. Each cell uses its `data-value` when present, otherwise its text. An empty query restores the records; a nonempty query with no marked columns matches none. Detail text does not contribute to search.

Record details stay with their record, and filtering preserves author `hidden` attributes, CSS hiding and `aria-expanded`. Select-all affects only enabled visible records. The framework's `data-table-filter-hidden` row attribute is reserved for filter state. Do not author it. Call `Expressive.DataTables.sync(wrapper)` after replacing records, changing the query from script or revealing a previously hidden table. Normal typing and the native search clear button need no script.

An optional named `output[data-table-filter-count]` inside the same wrapper, with `role="status"` and `aria-live="polite"`, receives the matching record count in the table's language. The count excludes details and author-hidden records, includes disabled records and leaves focus in the search input. Authors provide the labels and surrounding wording.

```html
<div class="data-table">
  <div class="field">
    <input id="customer-search" type="search" data-table-filter>
    <label for="customer-search">Search customers</label>
  </div>
  <span>Matching records: <output data-table-filter-count role="status" aria-live="polite" aria-label="Matching records"></output></span>
  <table>
    <caption>Customers</caption>
    <thead><tr><th scope="col" data-filter>Customer</th><th scope="col">Status</th></tr></thead>
    <tbody>
      <tr><th scope="row">Alvin</th><td>Shipped</td></tr>
      <tr><th scope="row">Alan</th><td>Pending</td></tr>
    </tbody>
  </table>
</div>
```

Each marked search input edit fires a bubbling, composed, cancelable `expressive:filter` from its wrapper, before local filtering. Its `{ query }` is trimmed but keeps its case; clearing the input requests `''`. For server filtering, call `preventDefault()` synchronously, before awaiting a response. Cancellation leaves the current view, order, count and focus unchanged. Filtering and sorting are separate cancelable requests.

```js
wrapper.addEventListener('expressive:filter', (event) => {
  event.preventDefault();
  loadPage({ query: event.detail.query });
});
```

`loadPage` is application code. The application handles fetching, failures and response ordering, replaces records, writes its server count and calls `Expressive.DataTables.sync(wrapper)`. Under server ownership, sync skips query matching and count writes, restores filter-owned hiding and synchronizes visible-record selection and columns. Author-hidden records and their details stay hidden. Sync also works inside the canceling listener and never emits filter, selection or sort events. Changing the input from script stays silent. For tables without accepted server page state, the next uncanceled input restores local filtering and counts for that wrapper.

### Local paging

Set `data-page-size="10"` on the `.data-table` wrapper to set the initial number of local record groups per page. The value must be a positive safe integer; absent or invalid sizes leave records unpaged. Provide named native `button[type="button"]` controls with `data-table-page="previous"` and `data-table-page="next"` inside a named `nav.pagination` in that wrapper. The framework uses the existing pagination styles and leaves all anchors and unmarked controls alone.

```html
<div class="data-table" data-page-size="2">
  <label>Customers per page
    <select class="browser-default" data-table-page-size>
      <option value="1">1</option>
      <option value="2" selected>2</option>
      <option value="3">3</option>
    </select>
  </label>
  <table>
    <caption>Customers</caption>
    <thead><tr><th scope="col">Customer</th></tr></thead>
    <tbody>
      <tr><th scope="row">Alvin</th></tr>
      <tr><th scope="row">Alan</th></tr>
      <tr><th scope="row">Jonathan</th></tr>
    </tbody>
  </table>
  <nav class="pagination" aria-label="Customer pages">
    <ol>
      <li><button type="button" data-table-page="previous">Previous</button></li>
      <li><output data-table-page-status role="status" aria-live="polite" aria-label="Pages"></output></li>
      <li><button type="button" data-table-page="next">Next</button></li>
    </ol>
  </nav>
</div>
```

An optional labelled single `select[data-table-page-size]` lets users choose a positive safe-integer size. Its native input or change updates the wrapper's `data-page-size` and preserves the current numeric page when available, otherwise clamps it to the last page. For seven records on page four at size two, choosing four shows page two of two and records five through seven. Size changes preserve details, selections and matching counts and emit no filter, sort or selection event. Each valid changed size requests cancelable `expressive:page` before its local default. Native selects (`browser-default`) and the existing FormSelect enhancement work in documents and open shadow roots. Disabled controls or fieldsets, multiple selects, unavailable options and invalid values are ignored without changing the working size. Unmarked controls and fixed-size tables retain their behavior.

The wrapper attribute remains authoritative for initial rendering and silent `sync(wrapper)`. Select the matching initial option. Changing only `select.value` from script is silent; dispatch a bubbling composed `input` or `change` event to apply it. Alternatively update the wrapper attribute and call sync, keeping the control's scripted value and presentation aligned yourself. Silent sync does not synthesize events or refresh FormSelect. Applications handle form resets.

Filtering and sorting run before paging. Details consume no page slots and stay with their record; author hiding and expansion state remain unchanged. Filter counts include all matching records across pages. Select-all changes only enabled records on the displayed page, while other selections remain checked. Its existing selection event still reports all selected records. The framework reserves `data-table-page-hidden` for page state; do not author it.

The first page is the default. An uncanceled filter input or a local sort returns to it. Canceling filter or sort applies no page reset. `Expressive.DataTables.sync(wrapper)` preserves the current page where possible, clamps it after row changes and emits no events. Call it after changing records or removing the size to restore paging-only hidden rows. Explicit local paging can slice server-filtered DOM records without rewriting the application's filter count; it derives totals from DOM records. Page and size controls request `expressive:page` before their local default; cancellation preserves the accepted view.

Page buttons keep focus, including at the first or last page. The framework sets `aria-disabled="true"` at bounds and ignores unavailable activation; buttons stay in the Tab order. Paging never moves focus into a row and emits no filter, sort or selection event. The optional status shows localized numbers such as `1 / 3` in the wrapper's language, or `0 / 0` with no records. Authors supply its accessible name and surrounding language. No numbered controls or page-size selector is generated. Standalone pagination remains CSS-only.

### Server paging

`Expressive.DataTables.setPage(wrapper, { page, pageSize, total })` accepts application page state and opts that wrapper into server ownership. Reuse the local paging markup. The wrapper must be a `.data-table` with a direct native table. Numeric safe integers are required: page and pageSize positive, total nonnegative. Missing fields, strings, fractions, infinities and unsafe integers return false without mutation. Valid state returns true, is copied without modifying the supplied object, clamps the page and reflects pageSize in the wrapper attribute. The setter silently synchronizes status, bounds, selection and columns.

```js
const wrapper = document.getElementById('orders');
// Render response records and matching-count text before accepting state.
Expressive.DataTables.setPage(wrapper, { page: 2, pageSize: 20, total: 87 });
wrapper.addEventListener('expressive:page', event => {
  event.preventDefault();
  loadPage(event.detail);
});
// After a later response replaces records and matching-count text:
Expressive.DataTables.setPage(wrapper, { page: 3, pageSize: 20, total: 87 });
// Exit server ownership, retaining the current size and starting locally at page one:
Expressive.DataTables.setPage(wrapper, null);
```

Valid changed page/size controls emit bubbling, composed, cancelable `expressive:page` from their own wrapper with `{ page, pageSize }`. Requested pages are positive and one-based, clamped against the known total. An empty table's size request asks for page one. Disabled/boundary buttons, unchanged accepted sizes and invalid controls make no request. A native composed input and its noncomposed change produce one request even when canceled or deferred. FormSelect's composed change works through the original select in documents and open shadow roots. For scripted size changes, dispatch one bubbling composed input or change.

Cancellation prevents the local default and preserves accepted state, rows, order, selection, counts and focus. A native select may already show the requested choice. Server mode always retains accepted page and size until another setter call, even if nobody cancels or handles the request. A newer nested page request or a setter call inside a listener supersedes an older request's local default. The application keeps select/FormSelect presentation aligned with accepted responses.

The application owns `loadPage`, fetching, errors, response ordering, safe DOM construction and matching-count text. Replace response records and count before accepting their state. Server totals never come from the returned row count. Every supplied author-visible record stays displayed, including on page two or when more than pageSize records are supplied. Details consume no slots. Zero total displays `0 / 0` and disables both directions without hiding application-provided records; render an empty body for an empty response.

Filter and sort events remain requests in server mode, without local filtering, reordering or page reset. Sorting still updates its requested aria-sort direction. Accept page one with a response when appropriate. Sync/setPage emit no events and preserve application counts, author-hidden/CSS-hidden records and details, expansion and selection. Select-all affects enabled displayed records only; its event still reports every selected DOM record. Null exits server ownership and reapplies local paging from page one using the current size. Independent canceled-filter ownership remains until the next uncanceled filter input restores local filtering.

### Bulk actions

A `.data-table-actions` bar written just before the `.data-table` joins its top edge and takes the selected color while any row is selected. A leading label, such as a count, pushes the buttons to the end. The page keeps the count current and decides which buttons apply; `hidden` removes the bar.

```html
<div class="data-table-actions">
  <span>2 selected</span>
  <button type="button" class="button text">Archive</button>
</div>
<div class="data-table">
  <table>…</table>
</div>
```

### Row expansion

A `tr.data-table-detail` right after a row shows only while that row holds a button with `aria-expanded="true"`. The page flips the attribute; the framework shows the row and turns the button's icon. Put the toggle in a `<td>`, because a button in a `<th>` is drawn as a sort control, and span the detail cell across the table.

```html
<tr>
  <th scope="row">Order 1042</th>
  <td>
    <button type="button" class="icon-button" aria-expanded="false" aria-controls="order-1042" aria-label="Details for order 1042">
      <span class="material-symbols" aria-hidden="true">expand_more</span>
    </button>
  </td>
</tr>
<tr id="order-1042" class="data-table-detail">
  <td colspan="2">Two items, shipped Monday.</td>
</tr>
```

### Scrolling

A wrapper that scrolls must be reachable from the keyboard. Give it `tabindex="0"`, `role="region"` and a name, usually the caption.

```html
<div class="data-table" tabindex="0" role="region" aria-labelledby="orders-caption" style="max-block-size: 320px">
  <table>
    <caption id="orders-caption">Orders</caption>
    <thead>
      <tr><th scope="col">Customer</th><th scope="col" class="numeric">Total</th></tr>
    </thead>
    <tbody>
      <tr><th scope="row">Alvin</th><td class="numeric">$87.00</td></tr>
    </tbody>
  </table>
</div>
```

### Sticky first column

Add `sticky-column` to the wrapper of a wide table to keep its first column in view while the other columns scroll sideways. When the first column holds row checkboxes, the name cell after it stays pinned too, and the checkbox column takes the fixed width of `--md-comp-data-table-checkbox-column-width`; change that token, not the cell, if you restyle the column. Pinned cells keep their row's hover and selected colors, and a divider marks their edge. Give each row one cell in the pinned column, because a first cell that spans several columns pins its whole width. Detail rows and the caption scroll with the table.

```html
<div class="data-table sticky-column" tabindex="0" role="region" aria-labelledby="accounts-caption">
  <table>
    <caption id="accounts-caption">Accounts</caption>
    <thead>
      <tr>
        <th scope="col"><label><input type="checkbox" aria-label="Select all accounts"></label></th>
        <th scope="col">Account</th>
        <th scope="col">Plan</th>
        <th scope="col">Region</th>
        <th scope="col">Owner</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><label><input type="checkbox" aria-label="Select Northwind"></label></td>
        <th scope="row">Northwind</th>
        <td>Business</td>
        <td>eu-west</td>
        <td>Ada Lovelace</td>
      </tr>
    </tbody>
  </table>
</div>
```

### Column resize

End a header cell in the first header row with a `<button type="button" class="drag-handle">` named after its column, and people can resize that column. They drag the handle, or focus it and press the arrow toward the column's end edge to widen it by 16px (64px with Shift) and the other arrow to narrow it. The keys mirror in a right-to-left page. Home and End jump to the narrowest and widest width, and a click, Enter or Space steps through narrow, middle and wide, the single-pointer alternative to dragging. A column is never narrower than `--md-comp-data-table-column-min-width` (48px) or wider than the wrapper's inner width. The first resize holds every column at its current width, so the others keep theirs, and the table scrolls sideways once its columns outgrow the wrapper. The handle is not a sort control, so a header cell can hold both.

Each change fires `expressive:resize` from the wrapper with `{ header, column, width }`, the width in pixels. The framework stores nothing: save widths from the event and restore them with `Expressive.DataTables.setWidth(header, px)`, which fires no event. `Expressive.DataTables.width(header)` reads a column's width. Put a handle only in a cell that spans one column. Under `sticky-column`, leave the checkbox column without a handle, because the pinned name cell sits at the checkbox column token's offset.

```html
<div id="orders" class="data-table" data-sort>
  <table>
    <thead>
      <tr>
        <th scope="col"><button type="button">Customer</button><button type="button" class="drag-handle" aria-label="Resize Customer"></button></th>
        <th scope="col">Status<button type="button" class="drag-handle" aria-label="Resize Status"></button></th>
        <th scope="col" class="numeric">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">Alvin</th><td>Shipped</td><td class="numeric">$87.00</td></tr>
    </tbody>
  </table>
</div>
```

```js
const wrapper = document.getElementById('orders');
const headers = wrapper.querySelectorAll('thead th');
const saved = JSON.parse(localStorage.getItem('order-widths') ?? '{}');
for (const [column, width] of Object.entries(saved)) Expressive.DataTables.setWidth(headers[column], width);
wrapper.addEventListener('expressive:resize', ({ detail }) => {
  saved[detail.column] = detail.width;
  localStorage.setItem('order-widths', JSON.stringify(saved));
});
```

### Column visibility

Give a menu checkbox item `data-column` set to the `id` of a header cell in the table's first header row, and the item shows and hides that column. While the item's `aria-checked` is `"false"`, the header cell and the column's cell in every body and footer row are hidden, and hidden cells leave the accessibility tree. A cell that spans several columns, such as a detail row's cell, stays. An item that is unchecked when the page loads starts its column hidden. The menu itself is an ordinary Menu, so it must be started, and its trigger can sit in a `.data-table-actions` bar.

The framework fires no event of its own. To keep the choice across visits, save it from the menu's `expressive:change`, which carries `{ item, checked }`, and render each item's `aria-checked` from what you saved. For a table or menu rendered after the page loaded, or after setting `aria-checked` from script, call `Expressive.DataTables.sync(wrapper)`. Name only header cells that span one column. Leave the row header column out of the menu, and under `sticky-column` the pinned columns too, because the pin follows the first cells in the markup, hidden or not. A resized table keeps its other widths when a column hides or shows.

```html
<div class="data-table-actions">
  <button type="button" class="button text menu-trigger" data-target="order-columns">Columns</button>
</div>
<div class="data-table">
  <table>
    <thead>
      <tr>
        <th scope="col">Customer</th>
        <th scope="col" id="order-status">Status</th>
        <th scope="col" id="order-total" class="numeric">Total</th>
      </tr>
    </thead>
    <tbody>
      <tr><th scope="row">Alvin</th><td>Shipped</td><td class="numeric">$87.00</td></tr>
    </tbody>
  </table>
</div>
<menu id="order-columns">
  <li role="menuitemcheckbox" aria-checked="true" data-column="order-status"><button type="button">Status</button></li>
  <li role="menuitemcheckbox" aria-checked="false" data-column="order-total"><button type="button">Total</button></li>
</menu>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-data-table-container-color` | `surface` |
| `--md-comp-data-table-shape` | 12px |
| `--md-comp-data-table-outline-color` | `outline-variant` |
| `--md-comp-data-table-header-height` | 56px; `dense`: 48px |
| `--md-comp-data-table-row-height` | 52px; `dense`: 40px |
| `--md-comp-data-table-cell-padding` | 16px |
| `--md-comp-data-table-selected-container-color` | `secondary-container` |
| `--md-comp-data-table-detail-container-color` | `surface-container-low` |
| `--md-comp-data-table-checkbox-column-width` | cell padding + 18px; used by `sticky-column` |
| `--md-comp-data-table-column-min-width` | 48px; the narrowest a resized column gets |

---

## Floating Action Button

Use a standalone FAB for the primary action or a FAB menu for related labelled actions.

### FAB menu

`fab-menu` is the FAB that expands into a list of labelled actions. The trigger is an ordinary FAB; the actions are a `<ul>` (or `<menu>`) of pills, each an icon and a label at 56dp on a full corner. Click the FAB to expand; click it again, click outside, or press Escape to collapse.

Expanded is the framework's state, not the author's: the constructor stamps `aria-expanded` on the trigger and `open()` / `close()` move it along with the `active` class. Do not write either into the markup.

```html
<div class="fab-menu">
  <button type="button" class="button extra circle" aria-label="Create">
    <span class="material-symbols" aria-hidden="true">add</span>
    <span class="material-symbols" aria-hidden="true">close</span>
  </button>
  <ul>
    <li><button type="button"><span class="material-symbols" aria-hidden="true">mode_edit</span><span>Compose</span></button></li>
    <li><button type="button"><span class="material-symbols" aria-hidden="true">image</span><span>Add photo</span></button></li>
  </ul>
</div>
```

Expanded, the FAB is the close button: the same disc on a full corner, the solid role colour rather than its container, and a 20dp glyph. Give the trigger a second icon and it becomes the close glyph — the first shows while collapsed, the second while expanded, and only ever one of them is in the box. With one icon the morph is the whole affordance.

The colour axis is the same three roles as the extended FAB, and one class moves both halves. The actions take `primary-container` (the default), `secondary-container` or `tertiary-container`; the close button takes the matching solid `primary`, `secondary` or `tertiary`.

```html
<div class="fab-menu secondary-container">…</div>
```

The `FloatingActionButton` runtime owns `active`, `aria-expanded` and inert closed actions. It opens upward on click. Escape, outside click and action activation close it; focus returns to the trigger only when closing would hide the focused action. Actions use ordinary Tab navigation. Beyond six actions the stagger runs out and the rest arrive together. Actions move on the fast spatial spring (`--md-comp-fab-menu-easing` and `--md-comp-fab-menu-duration`) and fade on the fast effects spring (`--md-comp-fab-menu-effects-easing` and `-effects-duration`), so `motion="expressive"` gives them a small bounce while the fade never overshoots.

Neither host declares `role="menu"`, and the trigger carries no `aria-haspopup`. The actions are reached with Tab, not the arrow keys, so the role is withheld rather than promised — see `SEMANTICS.md`.

| Token | Default |
| --- | --- |
| `--md-comp-fab-menu-close-button-container-height` | 56px |
| `--md-comp-fab-menu-close-button-container-width` | 56px |
| `--md-comp-fab-menu-close-button-icon-size` | 20px |
| `--md-comp-fab-menu-close-button-between-space` | 8px |
| `--md-comp-fab-menu-close-button-container-color` | `--md-sys-color-primary` |
| `--md-comp-fab-menu-close-button-icon-color` | `--md-sys-color-on-primary` |
| `--md-comp-fab-menu-menu-item-container-height` | 56px |
| `--md-comp-fab-menu-menu-item-between-space` | 4px |
| `--md-comp-fab-menu-menu-item-icon-size` | 24px |
| `--md-comp-fab-menu-menu-item-icon-label-space` | 8px |
| `--md-comp-fab-menu-menu-item-leading-space` | 24px |
| `--md-comp-fab-menu-menu-item-trailing-space` | 24px |
| `--md-comp-fab-menu-menu-item-container-color` | `--md-sys-color-primary-container` |
| `--md-comp-fab-menu-menu-item-label-text-color` | `--md-sys-color-on-primary-container` |

### Initialization

`Expressive.AutoInit()` starts `.fab-menu` unless marked `no-autoinit`. For manual ownership, use `FloatingActionButton.init(element)`. There are no component-specific options.

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.FloatingActionButton.getInstance(elem);
```

#### .open();

Opens the FAB menu.

```text
instance.open();
```

#### .close();

Closes the FAB menu.

```text
instance.close();
```

#### .destroy();

Destroy the plugin instance and tear down its event handlers.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `isOpen` | Boolean | Describes the open/close state of the FAB. |

### Speed-dial migration

Replace `.fab` and `.fixed-action-btn` wrappers with `.fab-menu` and labelled actions using the markup above. Remove `direction`, `hoverEnabled`, `toolbarEnabled`, their markup switches and speed-dial-only tokens. Hover opening, directional layouts and FAB-to-toolbar expansion have been removed. Use a current toolbar for persistent commands. Replace `circle extra small` with `circle extra`, and sizeless `extend` with `extend small`. Medium and large FABs, all three explicit extended sizes, and FAB menus remain supported. Use primary, secondary or tertiary container colors instead of surface-colored FABs; pair icon-only FAB colors with the matching on-container text role. General surface utilities remain supported outside FABs.

---

## Back to top

A link that appears once a long page has scrolled down, and returns to the top with focus on the main content.

Write an `<a class="back-to-top">` whose `href` names the page's main content, give it an `aria-label`, and add the FAB classes `button circle extra` for its look. `AutoInit()` starts every `.back-to-top`. The link sits in the bottom inline-end corner, `position: fixed`, and stays hidden until the window has scrolled past `threshold`, one viewport height by default. It hides again above it, unless it has focus: a focused link stays until focus leaves, so pressing Home on it does not drop focus to the body. Without the script the link is always visible and jumps to its target, as any in-page link does. Until the script starts it is also visible at the top of the page, so call `AutoInit()` before the first paint, from a script at the end of the body, to keep it from showing there.

```html
<main id="main">…</main>
<a class="back-to-top button circle extra" href="#main" aria-label="Back to top">
  <span class="material-symbols" aria-hidden="true">arrow_upward</span>
</a>
```

Activating the link scrolls the window to the top and moves focus to the `href` target, so the next Tab reaches the first control after it. A target that does not take focus, such as a `<main>`, gets `tabindex="-1"` until it loses focus. An `href` of `#` or `#top`, or one that names no element, focuses the body. The script replaces the jump, so the address keeps its fragment. A click with Ctrl, Meta, Shift or Alt, or one a page handler already prevented, is left to the browser. The scroll is smooth, and instant under reduced motion.

While hidden, the link is `inert` and `visibility: hidden`. Tab skips it, it takes no clicks, and screen readers do not list it. It fades in and hides at once, so a script that starts on a page already painted does not show it fading out. Under reduced motion it also appears at once. `inert` is the framework's state; don't write it in the markup.

The script watches the window's scroll through an `IntersectionObserver` on an empty element at the threshold, absolutely positioned at the start of the body, so scrolling runs no script. A body with `position: relative` measures the threshold from its own top edge. A pane, dialog or `.scroll-area` that scrolls on its own is not supported. A `.fab-menu` sits in the same corner; on a page with both, raise one with `--md-comp-back-to-top-offset`.

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `threshold` | String | `'100vh'` | How far the window scrolls before the link shows, as a CSS length from the top of the page. Viewport units follow window resizes. A value that is not a CSS length falls back to `'100vh'` with a warning. |

### Methods

#### .destroy()

Removes the observer and the threshold element, and any `tabindex` the script added, and shows the link again.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-back-to-top-offset` | 16px (from the bottom and inline-end edges) |
| `--md-comp-back-to-top-z` | 997 |
| `--md-comp-back-to-top-duration` | the fast effects spring's duration |
| `--md-comp-back-to-top-easing` | fast effects spring |

---

## Footer

Site navigation and extra information at the end of a page.

Footers are a good place for site navigation and extra information. This is where people look after they finish the page, or when they want more about the site.

Put the page in the three HTML5 landmarks `header`, `main`, and `footer`. Anatomy is the HTML: a bare `<footer>` is the component, each `<nav>` is a column of links with an `<h2>` heading, and a trailing `<small>` is the copyright bar. There is no `page-footer` or `footer-copyright` class, and no JavaScript component.

Expressive’s footer is unfilled by default. It draws a dashed top border and uses the theme tokens for paragraph and link color — not a solid primary bar.

```html
<footer>
  <section>
    <h2>Product</h2>
    <p>A column with no links is not navigation — it is a section with a heading.</p>
  </section>
  <nav aria-labelledby="footer-links">
    <h2 id="footer-links">Links</h2>
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
  </nav>
  <small>
    <span>&copy; 2026 Copyright Text</span>
    <a href="/privacy">Privacy</a>
  </small>
</footer>
```

The `<small>` bar is a flex row with space-between, so put the notice and the trailing link in as two children. A `right` float is not required.

### Sticky footer

A sticky footer stays at the bottom of the viewport when the page is short, and is pushed down when the content is taller. That is different from a fixed footer. Expressive does not include these rules — add them to your own stylesheet after you have the `header` / `main` / `footer` landmarks.

```text
body {
  display: flex;
  min-height: 100dvh;
  flex-direction: column;
}

main {
  flex: 1 0 auto;
}
```

---

## Icons

Google Material Icons and Material Symbols, sized with a few helper classes.

Expressive uses Material Symbols: outlined, rounded, and sharp. The class names are Google's.

**An icon is a `<span>`, not an `<i>`.** `<i>` means idiomatic text — a term, a thought, a phrase in another language — and an icon is none of those. A bare `<i>` gets no icon styling.

**An icon is either decoration or an image, and it has to say which.** The glyph is produced by a ligature, so the icon's text content is real text and a screen reader reads it: `<span class="material-symbols">add</span>` is announced as "add". Two correct forms:

- Decoration — `aria-hidden="true"`. The enclosing control carries the name, so an icon-only button needs its own `aria-label`. This is almost always what you want.
- An image in its own right — `role="img"` with an `aria-label`. Use this when the icon reports something no neighbouring text does, such as a badge count.

The size and float modifiers (`tiny`, `small`, `medium`, `large`, `left`, `right`) hang off the icon class, not off an element.

Google publishes a [Material Symbols list](https://fonts.google.com/icons?icon.set=Material+Symbols). That catalog is the source for ligature names.

The compiled stylesheet ships the outlined, rounded, and sharp variable fonts. Keep `dist/fonts/` next to `dist/css/` so the relative `url(../fonts/...)` paths resolve. Browsers download a family only after markup uses it.

```html
<span class="material-symbols" aria-hidden="true">add</span>
<span class="material-symbols-outlined" aria-hidden="true">add</span>
<span class="material-symbols-rounded" aria-hidden="true">add</span>
<span class="material-symbols-sharp" aria-hidden="true">add</span>
```

Icons inherit the current text color, so token utilities such as `primary-text` work. For icons inside buttons, see Buttons.

### Icon Sizes

Size an icon with `font-size`, or use the preset classes on the icon: `tiny` (1rem), `small` (2rem), `medium` (4rem), and `large` (6rem). They hang off the icon class, so they work on a `<span>` or a legacy `<i>` alike.

### Symbol Sizes

The same size classes apply to Material Symbols.

```html
<!-- Sizes: tiny 1rem, small 2rem, medium 4rem, large 6rem -->
<span class="material-symbols large" aria-hidden="true">insert_chart</span>
<span class="material-symbols-outlined large" aria-hidden="true">insert_chart</span>
<span class="material-symbols-rounded large" aria-hidden="true">insert_chart</span>
<span class="material-symbols-sharp large" aria-hidden="true">insert_chart</span>
```

---

## Navbar

Material Design 3 top app bars, from the HTML.

The bar is the markup. A `<header>` whose child is a `<nav>` is a top app bar. Top app bars stay pinned to the top by default, without a helper class. The heading is the headline; wrap it in `<hgroup>` with a `<p>` for a subtitle. Icon-only links and buttons are the leading and trailing actions. A `<menu>` (or `<ul>`) holds text destinations. A `<search class="search-bar">` in the nav is the search app bar. There is no `navbar`, `nav-wrapper`, or `brand-logo` class.

Small is 64dp at `title-large`. `medium` is the Expressive medium-flexible bar: 112dp / `headline-medium`, 136dp with a subtitle. `large` is large-flexible: 120dp / `display-small`, 152dp with a subtitle. `AppBar` (started by `AutoInit()`) collapses those two on scroll and opens the related search view when the search field is selected. Icons are 24dp in a 48dp target and inherit the header color so a fill + `on-*` pair stays readable.

Navigation rails and tabs are separate components. Do not nest either one in the header.

### Small

Default. Leading icon, headline, trailing actions. DOM order is the layout — the headline grows, so anything after it sits on the end.

```html
<header>
  <nav aria-label="Main">
    <button type="button" aria-label="Menu">
      <span class="material-symbols" aria-hidden="true">menu</span>
    </button>
    <h2>Title</h2>
    <button type="button" aria-label="Search"><span class="material-symbols" aria-hidden="true">search</span></button>
    <button type="button" aria-label="More"><span class="material-symbols" aria-hidden="true">more_vert</span></button>
  </nav>
</header>
```

### Destinations

Text links go in a `<menu>`. Put the menu after the heading to align it on the end; put it first to align it on the start. Hide it below the Expanded breakpoint and use a full-screen navigation dialog or modal expanded rail when the bar has to collapse.

```html
<header>
  <nav aria-label="Main">
    <h2>Title</h2>
    <menu>
      <li><a href="/sass">Sass</a></li>
      <li><a href="/components">Components</a></li>
      <li><a class="active" href="/javascript">JavaScript</a></li>
    </menu>
  </nav>
</header>
```

`active` goes on the link, not the list item. The state layer mixes `currentColor` at 8% hover and 10% active, so it follows the theme and any fill you put on the header.

### Center-aligned

Add `center` to the header. The headline is taken out of flow so the leading and trailing actions can sit on the edges without shifting it. Keep the title short enough that it does not run under the icons.

```html
<header class="center">
  <nav aria-label="Main">
    <button type="button" aria-label="Back">
      <span class="material-symbols" aria-hidden="true">arrow_back</span>
    </button>
    <h2>Title</h2>
    <button type="button" aria-label="More"><span class="material-symbols" aria-hidden="true">more_vert</span></button>
  </nav>
</header>
```

### Medium and large flexible

Same markup as the small bar. `medium` is the Expressive medium-flexible bar: 112dp with a `headline-medium` title on the second row. `large` is large-flexible: 120dp with `display-small`. The deprecated medium/large bars used `headline-small` / 152dp; do not recreate those. The title wraps to two lines. It is `order`ed onto the bottom row so the first row can hold the leading icon on the start and the trailing icons on the end. Add `center` as well to center that second-row title.

```html
<header class="medium">
  <nav aria-label="Main">
    <button type="button" aria-label="Back">
      <span class="material-symbols" aria-hidden="true">arrow_back</span>
    </button>
    <h2>Medium title</h2>
    <button type="button" aria-label="More"><span class="material-symbols" aria-hidden="true">more_vert</span></button>
  </nav>
</header>

<header class="large">…</header>
```

### Subtitle

A subtitle is an `<hgroup>` wrapping the heading and a `<p>`. There is no `subtitle` class. Small uses `label-medium`. Medium-flexible grows to 136dp and uses `label-large`. Large-flexible grows to 152dp and uses `title-medium`.

```html
<header class="medium">
  <nav aria-label="Main">
    <button type="button" aria-label="Back">
      <span class="material-symbols" aria-hidden="true">arrow_back</span>
    </button>
    <hgroup>
      <h2>Medium title</h2>
      <p>Subtitle</p>
    </hgroup>
    <button type="button" aria-label="More"><span class="material-symbols" aria-hidden="true">more_vert</span></button>
  </nav>
</header>
```

### Collapse

`AppBar` places a zero-height sentinel after `header.medium` and `header.large`, 64px above the header's bottom edge. When the page scrolls past the sentinel, the header gets `.collapsed` and the bar is the small 64dp row (`title-large`, single line). The sentinel moves with the page, so when scroll anchoring corrects the scroll position after the bar shrinks, the bar stays collapsed. It expands again once the page scrolls back to the top of the bar. `destroy()` removes the sentinel. The class is also legal to set by hand.

### Fixed

Every top app bar stays pinned with `position: sticky` and `top: 0`. No helper class or wrapper is required. At rest the bar is `surface`, the same as the page. Once content scrolls under it, supporting browsers fill it with `surface-container` via `animation-timeline: scroll()` so it separates from the body. Without that API the bar stays at rest.

The documentation header on this site is a fixed small bar. A second fixed bar on this page would sit on top of it, so the live example is the site header itself.

```html
<header>
  <nav aria-label="Main">
    <h2>Title</h2>
    <button type="button" aria-label="Search"><span class="material-symbols" aria-hidden="true">search</span></button>
  </nav>
</header>
```

### Color

The default fill is `surface`. Color utilities win because they live in the utilities layer — put `primary on-primary-text` (or any fill + `on-*` pair) on the header. Icons inherit the header color. Set `--md-comp-top-app-bar-leading-icon-color` to `var(--md-sys-color-on-surface-variant)` if you want the spec’s muted trailing icons.

```html
<header class="primary on-primary-text">
  <nav aria-label="Main">
    <h2>Primary</h2>
  </nav>
</header>
```

Component tokens you can set on the header (or on `:root`):

| Token | Default |
| --- | --- |
| `--md-comp-top-app-bar-container-color` | `--md-sys-color-surface` |
| `--md-comp-top-app-bar-scrolled-container-color` | `--md-sys-color-surface-container` |
| `--md-comp-top-app-bar-headline-color` | `--md-sys-color-on-surface` |
| `--md-comp-top-app-bar-leading-icon-color` | `inherit` (spec: `on-surface-variant`) |
| `--md-comp-top-app-bar-trailing-icon-color` | `inherit` (spec: `on-surface-variant`) |
| `--md-comp-top-app-bar-container-height` | 64px |
| `--md-comp-top-app-bar-leading-icon-size` | 24px |

### Menu

Point a `menu-trigger` at a `<menu>` whose `id` matches `data-target`. `AutoInit()` starts every `.menu-trigger`.

```html
<menu id="menu1">
  <li><button type="button">one</button></li>
  <li><button type="button">two</button></li>
  <li class="divider" role="separator"></li>
  <li><button type="button">three</button></li>
</menu>
<header>
  <nav aria-label="Main">
    <h2>Title</h2>
    <menu>
      <li>
        <a class="menu-trigger" href="#menu1" data-target="menu1">
          Menu<span class="material-symbols" aria-hidden="true">arrow_drop_down</span>
        </a>
      </li>
    </menu>
  </nav>
</header>
```

To initialize the menu yourself:

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.menu-trigger');
  Expressive.Menu.init(elems);
});
```

Menus open on click by default (`hover: false`). Pass `{ hover: true }` to open on hover instead.

### Search

The search app bar is a `<search class="search-bar">` in the nav, in place of the headline. Icons beside it sit outside the search; icons inside it stay on the search-bar rules. The input is centered. The related `.search-view` is named by `aria-controls` on the input, or is a `.search-view` descendant. Focusing or clicking the field opens a docked view. A full-screen view is a `dialog.search-view.full-screen` opened with `showModal()` on a click, Enter or ArrowDown, not on focus, so tabbing through the bar and focus returning after dismissal leave it closed (WCAG 3.2.1). An Enter that confirms an IME composition does not open it. Query text, native dialog behavior and application-managed focus are preserved. `destroy()` disconnects the collapse observer and removes the search focus, click and keydown listeners.

```html
<header>
  <nav aria-label="Main">
    <button type="button" aria-label="Menu">
      <span class="material-symbols" aria-hidden="true">menu</span>
    </button>
    <search class="search-bar" aria-label="Search">
      <span class="material-symbols" aria-hidden="true">search</span>
      <input type="search" placeholder="Search" aria-label="Search recipes" aria-controls="results" aria-expanded="false">
      <div class="search-view" id="results" hidden></div>
    </search>
    <a href="/account" aria-label="Account"><span class="material-symbols" aria-hidden="true">account_circle</span></a>
  </nav>
</header>
```

A `<form>` in the nav still fills the space between the leading action and anything after it. The input is unstyled against the bar and needs no extra field class. Its text uses the complete body-large type role, including letter-spacing, and follows overrides of that role's type tokens.

```html
<header>
  <nav aria-label="Main">
    <button type="button" aria-label="Back">
      <span class="material-symbols" aria-hidden="true">arrow_back</span>
    </button>
    <form role="search">
      <input type="search" placeholder="Search" aria-label="Search">
    </form>
  </nav>
</header>
```

### Responsive navigation

Hide wide text destinations below the Expanded breakpoint. Use a separate navigation bar for 3–5 compact destinations or a modal expanded navigation rail for up to 7. Split larger hierarchies into peer categories and a separate page-level list.

---


## Navigation bar

Switch between UI views on compact and medium screens. A `nav.navigation-bar` holds 3–5 destinations of equal importance. Destinations do not change from screen to screen. There is no JavaScript — mark the current view with `aria-current="page"` (or `active`).

This is not the app bar. The app bar names the current page and holds 1–2 actions. Use a navigation bar in compact windows, a bar or rail in medium windows, and an expanded navigation rail in wider windows.

### Stacked

Default. Icon above the label. The selected destination puts a pill behind the icon and fills the glyph.

```html
<nav class="navigation-bar" aria-label="Main">
  <a href="/" aria-current="page">
    <span class="material-symbols" aria-hidden="true">home</span>
    Home
  </a>
  <a href="/browse">
    <span class="material-symbols" aria-hidden="true">explore</span>
    Browse
  </a>
  <a href="/radio">
    <span class="material-symbols" aria-hidden="true">radio</span>
    Radio
  </a>
  <a href="/library">
    <span class="material-symbols" aria-hidden="true">library_music</span>
    Library
  </a>
</nav>
```

### Horizontal

Add `horizontal`. Icon and label sit on one row, and the selected pill wraps both. Use this in medium windows.

```html
<nav class="navigation-bar horizontal" aria-label="Main">
  <a href="/" aria-current="page">
    <span class="material-symbols" aria-hidden="true">home</span>
    Home
  </a>
  …
</nav>
```

### Fixed

Add `fixed` to stick the bar to the bottom of the viewport. Padding includes `safe-area-inset-bottom`.

```html
<nav class="navigation-bar fixed" aria-label="Main">…</nav>
```

---

## Navigation rail

Switch between UI views on mid-sized devices. A `nav.navigation-rail` holds 3–7 destinations plus an optional FAB. Put it in the same place on every screen.

Collapsed is 96dp with the icon above the label. Add `expanded` for 220–360dp, icon and label on one row, and an extended FAB. The menu button toggles that class (`AutoInit()` starts it). On compact windows an expanded rail is modal — a scrim, and Escape or a scrim tap collapses it. Add `modal` to keep that overlay at every breakpoint. Expanding and collapsing run on the default spatial spring (`--md-comp-nav-rail-motion` and `--md-comp-nav-rail-motion-duration`), so `motion="expressive"` lets the rail overshoot its width slightly before it settles.

Escape closes an open nested menu first. A second Escape collapses the modal rail and returns focus from inside it to the rail toggle, unless a close callback moves focus elsewhere.

A destination can open a side panel of more links. Make it a `<button popovertarget>` and put its `.navigation-rail-panel` popover right after it. The panel docks beside the rail at full height. A click toggles it; with a mouse, hovering the destination opens it and leaving the rail and panel closes it. A `<details>` in the panel is a dropdown group. Escape closes the panel before the rail and returns focus to its destination. A panel's destination is never the menu toggle, so a rail without one can start with it.

```html
<nav class="navigation-rail" aria-label="Main">
  <button type="button" aria-label="Menu">
    <span class="material-symbols" aria-hidden="true">menu</span>
  </button>
  <a class="button" href="/compose">
    <span class="material-symbols" aria-hidden="true">edit</span>
    <span>Compose</span>
  </a>
  <a href="/" aria-current="page">
    <span class="material-symbols" aria-hidden="true">star</span>
    Label
  </a>
  <a href="/two" aria-label="Starred, 3 unread">
    <span class="material-symbols" aria-hidden="true">star<span class="badge">3</span></span>
    Label
  </a>
</nav>
```

```html
<nav class="navigation-rail expanded" aria-label="Main">…</nav>
<nav class="navigation-rail expanded modal" aria-label="Main">…</nav>
```

```html
<button type="button" popovertarget="components-panel">
  <span class="material-symbols" aria-hidden="true">widgets</span>
  Components
</button>
<div id="components-panel" class="navigation-rail-panel" popover>
  <a href="/components" aria-current="page">Components overview</a>
  <details>
    <summary>Date &amp; time pickers</summary>
    <a href="/date-pickers">Date pickers</a>
    <a href="/time-pickers">Time pickers</a>
  </details>
</div>
```

Offset the rest of the page:

```css
@media (width >= 600px) {
  body {
    padding-left: var(--md-comp-nav-rail-collapsed-width);
  }
  body:has(.navigation-rail.expanded) {
    padding-left: var(--md-comp-nav-rail-expanded-width);
  }
}
```

---

## Pagination

Split long content into shorter, easier blocks.

Add pagination links to split long content into shorter blocks. The component is CSS-only: a `pagination` list of links. There is no JavaScript plugin.

The list lives in a `<nav aria-label="Pagination">` — a page's links are navigation, and the label distinguishes it from every other `<nav>` on the page.

Mark the current page with `active` on the `li` **and `aria-current="page"` on its link**; the class only fills it. Use `disabled` for unavailable prev/next, and make those a `<span>` rather than an `<a href>` — a disabled link is still focusable and still navigates. Icon-only prev/next links need an `aria-label`.

```html
<nav class="pagination" aria-label="Pagination">
  <ol>
    <li class="disabled">
      <span aria-hidden="true"><span class="material-symbols" aria-hidden="true">chevron_left</span></span>
    </li>
    <li class="active"><a href="?page=1" aria-current="page">1</a></li>
    <li><a href="?page=2">2</a></li>
    <li><a href="?page=3">3</a></li>
    <li><a href="?page=4">4</a></li>
    <li><a href="?page=5">5</a></li>
    <li>
      <a href="?page=2" aria-label="Next page"><span class="material-symbols" aria-hidden="true">chevron_right</span></a>
    </li>
  </ol>
</nav>
```

### Responsive

Add `nowrap` to keep a long run on one row and scroll it sideways instead of wrapping. The older `li.pages` nest does the same thing and is still styled.

`prev` and `next` are accepted and inert: they have no rules in the sheet. The 10%/80% split they used to drive is gone — a long run scrolls now rather than being clipped.

```html
<nav class="pagination" aria-label="Pagination">
  <ol>
    <li class="disabled prev">
      <span aria-hidden="true"><span class="material-symbols" aria-hidden="true">chevron_left</span></span>
    </li>
    <li class="pages">
      <ol>
        <li class="active"><a href="?page=1" aria-current="page">1</a></li>
        <li><a href="?page=2">2</a></li>
        <li><a href="?page=3">3</a></li>
      </ol>
    </li>
    <li class="next">
      <a href="?page=2" aria-label="Next page"><span class="material-symbols" aria-hidden="true">chevron_right</span></a>
    </li>
  </ol>
</nav>
```

---

## Panes

Material 3 canonical layouts — list-detail, supporting pane, and equal panes.

Panes are CSS-only. A container (`panes`, or one of the named aliases `list-detail`, `supporting-pane-layout`, `pane-layout`) holds two or three `pane` children. Layout decisions use the nearest ancestor with inline-size or size containment, falling back to viewport media rules when none exists. Set `container-type: inline-size` on the containing column for nested layouts. The pane layout cannot query its own size. Compact widths (`< 600px`) use 16px inline margins; wider layouts use 24px margins and spacers. Below 840px only one pane shows at a time; at 840px and up the panes sit side by side. Google recommends stacking supporting content below primary content on compact and medium layouts; the current one-active-pane behavior is a framework adaptation. Footer actions wrap to keep translated labels reachable.

Any of `pane`, `list-pane`, `primary-pane`, `detail-pane`, and `supporting-pane` counts as a pane child — the specific names are for readability.

### List-detail

`list-detail` gives a 360px list pane and a flexible detail pane.

```html
<div class="panes list-detail">
  <div class="list-pane">
    <header>
      <h2>Inbox</h2>
    </header>
    <ul class="list">
      <li><a href="/inbox/brunch">Brunch this weekend?</a></li>
      <li><a href="/inbox/design-review">Design review</a></li>
    </ul>
  </div>
  <div class="detail-pane">
    <header>
      <button aria-label="Back"><span class="material-symbols" aria-hidden="true">arrow_back</span></button>
      <h2>Brunch this weekend?</h2>
    </header>
    <section>
      <p>Detail content.</p>
    </section>
  </div>
</div>
```

### Compact

At Compact widths (`< 600px`) the container has 16px inline margins. Medium and wider widths use 24px inline margins and 24px spacers. Below 840px the container shows one pane. The first pane wins by default; add `active` to the pane you want instead, and move that class to switch panes. At 840px and up `active` is ignored and every pane shows. These thresholds use the applicable ancestor query container, or the viewport fallback.

```html
<div class="panes list-detail">
  <div class="list-pane">…</div>
  <div class="detail-pane active">…</div>
</div>
```

### Supporting pane

`supporting` (or `supporting-pane-layout`) puts a flexible primary pane first and a 360px supporting pane after it. Add `start` (or `left`) to lead with the supporting pane instead.

```html
<div class="panes supporting">
  <div class="primary-pane">…</div>
  <div class="supporting-pane">…</div>
</div>
```

### Equal

`equal` splits the container `1fr 1fr`.

```html
<div class="panes equal">
  <div class="pane">…</div>
  <div class="pane">…</div>
</div>
```

### Three-pane

`three-pane` is list + primary + supporting. It stays two-up until 1200px, then expands to three columns.

```html
<div class="panes three-pane">
  <div class="list-pane">…</div>
  <div class="primary-pane">…</div>
  <div class="supporting-pane">…</div>
</div>
```

### Resizing

A `<button class="drag-handle">` between the two panes of a list-detail or supporting layout resizes them at 840px and up. It takes the spacer's place and replaces the divider. Drag it, or press Left and Right to move it 16px (Shift for 64px), Home and End for the narrowest and widest, or click it (Enter and Space too) to step through narrow, middle and wide. The click is the single-pointer alternative to dragging. A resized pane stays at least 240px wide, and the other keeps `--md-comp-pane-primary-min-width`. The width is written to `--md-comp-pane-list-width` or `--md-comp-pane-supporting-width` on the layout's style; save it to restore a user's choice. The root bundle starts `PaneResize` on load, and the modular entry with `PaneResize.Init()`. Equal and three-pane layouts do not resize. `PaneResize.setWidth(handle, px)` and `PaneResize.width(handle)` work from script.

```html
<div class="panes list-detail">
  <div class="list-pane">…</div>
  <button type="button" class="drag-handle" aria-label="Resize list"></button>
  <div class="detail-pane">…</div>
</div>
```

### Appearance

The default is coplanar: full-bleed surfaces with a 1px `outline-variant` divider between them. `separated` (alias `floating`) instead gives each pane a 16px rounded `surface-container-low` card with a 24px gap. Inside a separated layout, a pane can take `elevated` for a shadow or `outlined` for a `surface` fill with a border.

```html
<div class="panes list-detail separated">
  <div class="list-pane outlined">…</div>
  <div class="detail-pane elevated">…</div>
</div>
```

### Pane anatomy

A pane is a column. A direct `header` (or `pane-header`) is a 64px title bar — its heading takes Title Large and truncates, and an icon-only `button` in it becomes a 48px round icon button. A direct `main`, `div`, `pane-content`, `pane-body`, or `list` is the scrolling body. A direct `footer`, `pane-footer`, or trailing `nav` is a 56px action bar with a top divider.

```html
<div class="pane">
  <header>
    <button aria-label="Back"><span class="material-symbols" aria-hidden="true">arrow_back</span></button>
    <h2>Title</h2>
    <button aria-label="More options"><span class="material-symbols" aria-hidden="true">more_vert</span></button>
  </header>
  <section>
    <p>Scrolling content.</p>
  </section>
  <footer>
    <button class="button">Save</button>
  </footer>
</div>
```

### Tokens

Set these on the container to resize a layout.

| Name | Default | Description |
| --- | --- | --- |
| `--md-comp-pane-margin` | `16px` Compact; `24px` Medium and wider | Inline window-edge margin. |
| `--md-comp-pane-gap` | `24px` | Medium-and-wider spacers; gap and padding in the separated appearance. |
| `--md-comp-pane-divider-color` | `outline-variant` | Coplanar divider and `outlined` pane border. |
| `--md-comp-pane-container-color` | `surface` | Container fill. |
| `--md-comp-pane-container-shape` | `0px` (`16px` when separated) | Pane corner radius. |
| `--md-comp-pane-list-width` | `360px` | List pane width at 840px and up. |
| `--md-comp-pane-supporting-width` | `360px` | Supporting pane width at 840px and up. |
| `--md-comp-pane-primary-min-width` | `360px` | Minimum width for the primary pane. |

---

## Progress indicators

Native and custom progress use system colors in forced-colors mode, outline empty linear tracks, and preserve circular value segments. Reduced motion retains static feedback. Browser emulation checks do not replace Windows high-contrast or screen-reader review.

Activity and progress indicators for content that takes time to load.

If content will take a while to load, give the user feedback. Expressive ships linear progress bars and circular spinners. Both are CSS-only — there is no JavaScript plugin.

For an indeterminate wait under about five seconds, prefer the [Loading indicator](#loading-indicator) — it supersedes the indeterminate circular case below. Progress indicators keep both linear bars and everything determinate.

Prefer `<progress>`: it reports itself, and its value with it. A `<div class="progress">` is a bar drawn with CSS and reports nothing, so it needs `role="progressbar"` — and if it is determinate, `aria-valuenow` as well. A progressbar with no value is an *indeterminate* one by definition, which is a lie if the bar visibly shows 70%.

### Linear

Custom linear fills start at the text-direction start edge: left in LTR and right in RTL. Indeterminate motion and reduced-motion static fills follow the same direction, including inherited and nested overrides. Native determinate progress and circular indicators are unchanged.

There are two linear bars: determinate and indeterminate.

#### Determinate

A native `<progress class="progress">` is the simplest form. The `.progress` +
`.determinate` pair still works, and `--md-comp-progress-value` sets the fill
without inline widths. The bar uses the primary token on a `secondary-container`
track.

```html
<progress class="progress" value="70" max="100"></progress>

<div class="progress" role="progressbar" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100">
  <div class="determinate" style="width: 70%"></div>
</div>

<div class="progress" role="progressbar" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100" style="--md-comp-progress-value: 70%"></div>
```

#### Indeterminate

Use a valueless `<progress>`, or `.indeterminate`, when you cannot report a
percentage.

```html
<progress class="progress"></progress>

<div class="progress" role="progressbar">
  <div class="indeterminate"></div>
</div>
```

#### End stop and wave

`stop` puts a dot, `--md-comp-progress-stop-size` (4px) across, in the indicator colour at the track's inline end, so the end reads when the track's contrast falls short. `wavy` draws the indicator as a 4px wave, 3px either side of a thin track, by masking it; the bar is 14px tall and `--md-comp-progress-wavelength` (40px) sets the period. Both work on `<progress>`, a `.determinate` child and a bare bar, and `wavy` also on an indeterminate bar. `progress circular wavy` draws a circular indicator as a wave round the ring, over a thin plain track when it is `determinate`; a spinner has no track.

```html
<span class="progress circular wavy determinate" role="progressbar" aria-valuenow="65" aria-valuemin="0" aria-valuemax="100" style="--md-comp-progress-value: 65%"></span>
```

```html
<progress class="progress stop" value="40" max="100"></progress>
<progress class="progress wavy stop" value="60" max="100"></progress>
```

### Circular

A circular indicator is a single `<span class="progress circular">`. There is no wrapper,
no layers and no clippers — the whole spinner is one element. The default is
40dp; add `small` (24dp) or `big` (64dp).

```html
<span class="progress circular" role="status" aria-label="Loading"></span>
<span class="progress circular small" role="status" aria-label="Loading"></span>
<span class="progress circular big" role="status" aria-label="Loading"></span>
```

#### Determinate

Add `determinate` and set the progress with `--md-comp-progress-value`. Report
the value to assistive technology with the `progressbar` role.

```html
<span class="progress circular determinate"
      style="--md-comp-progress-value: 70%"
      role="progressbar" aria-valuenow="70"
      aria-valuemin="0" aria-valuemax="100"
      aria-label="Loading"></span>
```

#### Color

The indicator follows `--md-comp-progress-indicator`, so it themes with the rest
of the page. Override it for a one-off color — a role token, never a raw hex.

```html
<span class="progress circular"
      style="--md-comp-progress-indicator: var(--md-sys-color-error)"
      role="status" aria-label="Loading"></span>
```

### Meter

A `<meter>` shows a measurement within a known range, such as storage used or password strength. Use `<progress>` for progress toward completion. Every `<meter>` gets an 8px bar on a `secondary-container` track with no class needed. The fill color follows the native `low`, `high` and `optimum` attributes: `primary` in the optimum region, `tertiary` one region away and `error` two away. Give it a visible label.

```html
<label for="storage">Storage: 7.2 of 10 GB</label>
<meter id="storage" min="0" max="10" low="7" high="9" optimum="0" value="7.2">7.2 GB</meter>
```

Tokens on the `meter`: `--md-comp-meter-track`, `--md-comp-meter-optimum`, `--md-comp-meter-suboptimum`, `--md-comp-meter-even-less-good`, `--md-comp-meter-thickness` (8px) and `--md-comp-meter-shape` (4px). In forced-colors mode the fill is `CanvasText` on a `Canvas` track with an outline.


## Search

A search bar, and the view it expands into.

A `<search>` with `search-bar` on it is the bar: a leading icon or icon button, an `<input type="search">`, and whatever trailing actions the query needs. The element is the landmark, so there is no `role="search"` to add. The input goes in bare — the bar is the container, so none of the text-field chrome (`.field`, the underline, the floating label) applies.

Spacing follows what is at each end: a bare glyph sits 16dp from the edge, an icon button 4dp, because the button already insets its own glyph by 12dp. An `<img>` in the bar is the account avatar — 30dp and circular; put it inside the button when it is tappable, so the 48dp target comes from the button.

```html
<search class="search-bar" aria-label="Search">
  <span class="material-symbols" aria-hidden="true">search</span>
  <input type="search" aria-label="Search recipes" placeholder="Search recipes">
  <button type="button" class="icon-button" aria-label="Filters">
    <span class="material-symbols" aria-hidden="true">tune</span>
  </button>
</search>
```

### Docked view

`.search-view` is the surface the bar expands into. Docked, it goes inside the bar — it hangs off it, so it needs no coordinates of its own — and is shown and hidden with the `hidden` attribute. It is a plain element rather than a `<dialog>` on purpose: `dialog.show()` moves focus into the dialog, which would take the caret out of the input the user is typing in.

```html
<search class="search-bar" aria-label="Search">
  <span class="material-symbols" aria-hidden="true">search</span>
  <input type="search" aria-label="Search fruit" placeholder="Search fruit"
         onfocus="document.getElementById('results').hidden = false">
  <div class="search-view" id="results" hidden>
    <ul class="list">
      <li><a href="/apricot"><span class="material-symbols" aria-hidden="true">history</span><span>Apricot</span></a></li>
    </ul>
  </div>
</search>
```

### Full-screen view

Full screen, the view is a `<dialog class="search-view full-screen">` opened with `showModal()`; Escape and a tap outside come from the platform. Its header is another `.search-bar`, with the pill and the shadow taken off. `autofocus` on the input is required, not decorative: `showModal()` focuses the first focusable descendant — the back button — so without it a keyboard user cannot type.

Full screen at Compact and docked from Medium up is the M3 rule of thumb, and the app picks: no breakpoint in the sheet swaps one for the other, and the two are not interchangeable markup (the docked view nests inside the bar, the full-screen one is a top-level `<dialog>`). Render the one the window calls for.

```html
<dialog class="search-view full-screen" id="search-full" aria-label="Search">
  <search class="search-bar" aria-label="Search">
    <form method="dialog">
      <button type="submit" class="icon-button" aria-label="Back">
        <span class="material-symbols" aria-hidden="true">arrow_back</span>
      </button>
    </form>
    <input type="search" autofocus aria-label="Search fruit" placeholder="Search fruit">
  </search>
  <hr>
  <ul class="list">
    <li><a href="/apricot"><span class="material-symbols" aria-hidden="true">history</span><span>Apricot</span></a></li>
  </ul>
</dialog>
```

### Suggestions

Search ships no suggestion list of its own. Put `autocomplete` on the bar's input and you get the combobox, its listbox, and the arrow-key and `aria-activedescendant` handling that Autocomplete already implements and is tested for.

```html
<search class="search-bar" aria-label="Search">
  <span class="material-symbols" aria-hidden="true">search</span>
  <input type="search" class="autocomplete" aria-label="Search fruit" placeholder="Search fruit">
</search>
```

That is the whole relationship between the two, and it is deliberate. A `.search-view` takes no composite role: its contents are links and buttons reached with Tab, and a second listbox here would be the same promise as Autocomplete's with no keyboard model behind it. Use the view for recent searches, filters and results; use Autocomplete for a list the user arrows through.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-search-bar-container-color` | `--md-sys-color-surface-container-high` |
| `--md-comp-search-bar-container-height` | 56px |
| `--md-comp-search-bar-container-shape` | 9999px (full) |
| `--md-comp-search-bar-leading-space` | 16px, or 4px beside an icon button |
| `--md-comp-search-bar-trailing-space` | 16px, or 4px beside an icon button |
| `--md-comp-search-bar-icon-label-space` | 16px |
| `--md-comp-search-bar-icon-size` | 24px |
| `--md-comp-search-bar-avatar-size` | 30px |
| `--md-comp-search-view-container-color` | `--md-sys-color-surface-container-high` |
| `--md-comp-search-view-container-shape` | 12px, 0 full-screen |
| `--md-comp-search-view-header-height` | 56px, 72px full-screen |
| `--md-comp-search-view-divider-color` | `--md-sys-color-outline` |
| `--md-comp-search-view-bar-results-gap` | 2px |
| `--md-comp-search-view-max-height` | 60vh, none full-screen |


---

## Loading indicator

M3 Expressive's indicator for a short wait — a shape that morphs while it spins.

Reach for it whenever the wait is under about five seconds and you cannot report
a percentage. It replaces most short indeterminate circular uses. Keep a progress
indicator for a process that will transition to a determinate value; Google's
button guidance also retains circular progress. `.progress` supports both linear
bars and determinate cases.

One element, CSS-only, no JavaScript plugin. The element is empty, so it reports
nothing on its own. Supply a named waiting state and meaningful status text updates;
verify spoken delivery with assistive technology. An indeterminate `progressbar`
is also valid and omits `aria-valuenow`. The current framework contract requires a named status. A name alone does not prove an announcement.

```html
<span class="loading-indicator" role="status" aria-label="Loading"></span>
```

### Contained

`contained` puts the indicator on a `secondary-container` circle and switches it
to `on-secondary-container`. Use it over an image or a coloured surface, where
the plain indicator would be lost.

```html
<span class="loading-indicator contained" role="status" aria-label="Loading"></span>
```

### Tokens

| Token | Default | What it sets |
| --- | --- | --- |
| `--md-comp-loading-indicator-active-indicator-size` | `38px` | The morphing shape. |
| `--md-comp-loading-indicator-container-size` | `48px` | The box it sits in — the visible circle when `contained`. |
| `--md-comp-loading-indicator-active-indicator-color` | `primary` | The shape's fill. |
| `--md-comp-loading-indicator-container-color` | `transparent` | The container fill. |
| `--md-comp-loading-indicator-duration` | `3000ms` | One full morph-and-spin cycle. |

```html
<span class="loading-indicator"
      style="--md-comp-loading-indicator-active-indicator-color: var(--md-sys-color-error)"
      role="status" aria-label="Loading"></span>
```

`prefers-reduced-motion: reduce` stops both animations and leaves the circle.


---

# JavaScript components

## Skeleton

Placeholder shapes that hold the layout while content loads.

A `.skeleton` is an empty block in the shape of the content it stands in for, with a shimmer passing across it. Size the default box with `width` and `height`. `text` is one line of body text, and the last of several lines runs shorter. `circle` is a 40px circle for an avatar. Reduced motion stops the shimmer and keeps the shapes. CSS only; there is no JavaScript.

Skeletons say nothing to assistive technology. Mark the region being filled with `aria-busy="true"` and report the wait in a status, such as a loading indicator or visually hidden `role="status"` text. Swap in the content and remove `aria-busy` in the same update.

```html
<p class="visually-hidden" role="status">Loading messages</p>
<div class="flex gap-4" aria-busy="true">
  <div class="skeleton circle"></div>
  <div class="flex-1">
    <div class="skeleton text"></div>
    <div class="skeleton text"></div>
  </div>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-skeleton-color` | `surface-container-highest` |
| `--md-comp-skeleton-shimmer-color` | `surface` at 50% |
| `--md-comp-skeleton-shape` | 8px; `text`: 4px |
| `--md-comp-skeleton-circle-size` | 40px |

---

## Empty state

What a view shows when it has nothing to list: an icon, a heading, text and actions.

A `.empty-state` centers its content in the space the missing items would fill. In order, it holds an optional leading icon, which sits in a 96px tonal circle, or an `<img>` illustration; a heading at the level the page outline needs; a `<p>` of supporting text; and a `.empty-state-actions` row of buttons. CSS only; there is no JavaScript.

Say what is empty and what the user can do about it. Use it for a first run, a search with no results, or content the user cannot see yet. Hide the icon from assistive technology, and give an illustration `alt=""` when the heading already says what it shows.

```html
<div class="empty-state">
  <span class="material-symbols" aria-hidden="true">inbox</span>
  <h2>No messages yet</h2>
  <p>Messages from your team show up here.</p>
  <div class="empty-state-actions">
    <button class="filled">New message</button>
  </div>
</div>
```

### Search with no results

When a filter or search empties a list, offer the way back as the action.

```html
<div class="empty-state">
  <span class="material-symbols" aria-hidden="true">search_off</span>
  <h3>No results for "invoices 2019"</h3>
  <p>Check the spelling or search all folders.</p>
  <div class="empty-state-actions">
    <button class="outlined">Clear search</button>
  </div>
</div>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-empty-state-padding` | 48px 24px |
| `--md-comp-empty-state-max-width` | 40ch (heading and text) |
| `--md-comp-empty-state-icon-size` | 48px |
| `--md-comp-empty-state-icon-container-size` | 96px |
| `--md-comp-empty-state-icon-container-color` | `surface-container-high` |
| `--md-comp-empty-state-image-max-width` | 240px |

## Alert

A message that stays in the page next to the content it is about, with optional actions.

An `.alert` holds an optional leading icon, then the message: a heading, a `<p>`, or both. It stays until the problem goes away or the user dismisses it. Use a snackbar for brief feedback after an action, and a dialog when the user must decide before going on. CSS only; there is no JavaScript.

```html
<div class="alert">
  <span class="material-symbols" aria-hidden="true">info</span>
  <p>Your trial ends in 3 days. <a href="/plans">See plans</a></p>
</div>
```

### Critical

`critical` uses the error container colors for problems that stop something from working. There are no success or warning tones: Material has no color roles for them.

```html
<div class="alert critical">
  <span class="material-symbols" aria-hidden="true">error</span>
  <h3>Payment failed</h3>
  <p>Your card was declined. Update it to keep your workspace active.</p>
</div>
```

### Actions and close

An `.alert-actions` row puts buttons under the message, aligned to the end. An `.icon-button` as the last child is the close button, and it stays beside the first line. The page removes a dismissed alert and moves focus to something nearby.

```html
<div class="alert">
  <span class="material-symbols" aria-hidden="true">cloud_off</span>
  <p>You're offline. Edits are saved on this device and sync when the connection returns.</p>
  <div class="alert-actions">
    <button class="button text" type="button">Retry sync</button>
  </div>
  <button class="icon-button" type="button" aria-label="Dismiss">
    <span class="material-symbols" aria-hidden="true">close</span>
  </button>
</div>
```

### Announcing changes

An alert that is in the page when it loads needs no role. When one appears or changes during use, put `role="status"` on its text, or `role="alert"` when the user must act now. The live element must be in the page before its text is written, and it must not hold buttons or links, because a screen reader reads their labels as part of the message. Never use `role="banner"`, which is the page header landmark.

```html
<div class="alert critical">
  <span class="material-symbols" aria-hidden="true">error</span>
  <p role="alert" id="save-error"></p>
  <div class="alert-actions">
    <button class="button text" type="button">Try again</button>
  </div>
</div>
```

### Error summary

An `.error-summary` alert at the top of a `novalidate` form lists what is wrong when the form is submitted. While any field is invalid, the submit stops as it would natively, and the Forms behavior writes one link per invalid field into the summary's `<ul>`: the field's label and the browser's validation message. A radio group gets one link, named by its legend. The summary then shows and takes focus, so a screen reader reads it, and each link focuses its field. A valid submit hides it again, and a submit button with `formnovalidate` skips the check. `novalidate` is required: without it the browser blocks the submit event and shows only the first field's bubble. Keep each field's own error text; the summary only gathers them.

```html
<form novalidate>
  <div class="alert critical error-summary" hidden aria-labelledby="problem">
    <span class="material-symbols" aria-hidden="true">error</span>
    <h2 id="problem">There is a problem</h2>
    <ul></ul>
  </div>
  <label for="email">Email address</label>
  <input id="email" type="email" required>
  <button type="submit">Send</button>
</form>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-alert-container-color` | `secondary-container`; critical: `error-container` |
| `--md-comp-alert-color` | `on-secondary-container`; critical: `on-error-container` |
| `--md-comp-alert-shape` | 16px |
| `--md-comp-alert-padding` | 16px |

---

## Popover

A panel that opens from a button and stays until dismissed, on the native popover attribute.

A `.popover` is a `[popover]` element, and the button that opens it names it with `popovertarget` (or `commandfor` with `command="toggle-popover"`). The browser owns the behavior: pressing the button toggles the panel, Escape or a click outside closes it, focus returns to the button, and the button reports the panel as expanded. CSS anchor positioning places the panel 4px below the button with their start edges aligned, and flips it to the other side when it would not fit. A browser without anchor positioning centers the panel in the viewport instead. CSS only; there is no JavaScript.

It looks like a rich tooltip but behaves as a disclosure: it opens on press, stays open while the user reads or acts, and can hold links and buttons. A small one behind an info icon button is a toggletip. Add `top` to open above, or `start` or `end` to open beside the button, centered on it.

```html
<button class="icon-button" type="button" popovertarget="storage-help" aria-label="About storage">
  <span class="material-symbols" aria-hidden="true">info</span>
</button>
<div id="storage-help" class="popover" popover>
  <h3>Storage</h3>
  <p>Files in the trash count toward your quota until you empty it.</p>
  <div class="popover-actions">
    <button type="button" class="text">Empty trash</button>
  </div>
</div>
```

### Accessibility

Name an icon-only button for what it explains, not "Info". The panel follows the button in the reading order, so a screen reader reaches it next; the browser does not move focus into it. Keep the panel short. A form or anything the user must finish belongs in a dialog.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-popover-container-color` | `surface-container` |
| `--md-comp-popover-shape` | 12px |
| `--md-comp-popover-padding` | 12px 16px |
| `--md-comp-popover-max-width` | 320px |
| `--md-comp-popover-gap` | 4px |

---

## Hover card

Shows a card of preview content after a delay when a link is hovered or gets keyboard focus.

Add `hover-card-trigger` to an `<a href>` and point its `aria-details` at the card, a `.popover` with `popover="hint"`. `AutoInit()` starts every `.hover-card-trigger`. Once the pointer or keyboard focus has rested on the link for `enterDelay`, 500 ms by default, the card shows against the link: below it with start edges aligned, flipped to the other side when it would not fit. A pointer that only passes over the link shows nothing.

The card stays while the pointer is on the link or the card, and while focus is on either. When both are left, it hides after `exitDelay`, 300 ms by default, so a pointer that crosses the gap to the card keeps it open. Focus stays on the link when the card shows, and Tab moves into the card's links and buttons. Escape hides the card and takes focus in it back to the link. A click elsewhere hides it too. One hint card shows at a time, and a hover card leaves an open menu or popover alone. Several links can name the same card, such as a name mentioned twice, and the card shows against whichever link showed it last.

A tap or click follows the link and shows no card, so the linked page has to hold the same content for touch screens. Keep the card a short preview of where the link goes, and put anything to act on in the page, a popover opened by a button or a dialog. Do not also give the link `interestfor`, which Chromium would handle on its own as well.

```html
<p>Written by <a class="hover-card-trigger" href="/people/ada" aria-details="ada-card">Ada Lovelace</a>.</p>
<div id="ada-card" class="popover" popover="hint">
  <h3>Ada Lovelace</h3>
  <p>Mathematician. Wrote the first published program, for the Analytical Engine.</p>
  <p><a href="/people/ada/articles">All articles by Ada</a></p>
</div>
```

### Accessibility

`aria-details` tells a screen reader that the link has more detail and lets it move there. Screen readers support it unevenly, and a hidden card is not in the reading order at all, which is another reason the linked page must hold the same content. Leave the role off the card: it can hold links, which a tooltip cannot.

### Options

Constructor and `init` options. `data-hover-card-options` sets them from the markup.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `enterDelay` | Number | `500` | How long the pointer or keyboard focus rests on the link before the card shows, in milliseconds. |
| `exitDelay` | Number | `300` | How long the card stays after the pointer and focus leave the link and the card, in milliseconds. |

### Methods

#### .open()

Shows the card now, without `enterDelay`.

#### .close()

Hides the card now, without `exitDelay`.

#### .destroy()

Hides a card the instance showed and removes the listeners. The link and card keep the markup as written.

### Events

`expressive:open` and `expressive:close` fire from the link when the card shows and hides, also when the browser hides it on Escape or a click elsewhere.

---

## Stepper

Progress through a fixed sequence of steps, such as a checkout, with each step's state.

A `.stepper` is an `<ol>`, and each `<li>` is a step whose text is its label. The list numbers the steps. Mark the step the user is on with `aria-current="step"`, finished steps with `complete`, and a step that needs attention with `invalid`; the page keeps these current as the user moves. A complete step shows a check and an invalid one shows "!", and both states are spoken before the label through the indicator's alt text. CSS only; there is no JavaScript.

A `<small>` after the label is supporting text, such as "Optional". A finished step can link back with an `<a>`. Steps never shrink below their label: a row too narrow for all of them wraps. Add `vertical` to stack the steps, which suits compact windows, and put a `.stepper-content` block in a vertical step to show its form or summary under the label.

```html
<ol class="stepper">
  <li class="complete"><a href="/checkout/cart">Cart</a></li>
  <li aria-current="step">Shipping<small>Address and speed</small></li>
  <li>Payment</li>
  <li>Review</li>
</ol>
```

### Vertical

```html
<ol class="stepper vertical">
  <li class="complete">Account</li>
  <li aria-current="step">
    Profile<small>Optional</small>
    <div class="stepper-content">Add a photo and a short bio.</div>
  </li>
  <li>Done</li>
</ol>
```

### Translation

The spoken states are tokens. Set them in the page's language on the stepper or any ancestor.

```css
:root:lang(es) {
  --md-comp-stepper-complete-label: "Completado";
  --md-comp-stepper-error-label: "Error";
}
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-stepper-indicator-size` | 24px |
| `--md-comp-stepper-connector-color` | `outline-variant`; after a complete step: `primary` |
| `--md-comp-stepper-active-color` | `primary` |
| `--md-comp-stepper-complete-label` | `"Completed"` |
| `--md-comp-stepper-error-label` | `"Error"` |

---

## Timeline

Events in order, each with a time, a title and details, joined by a line.

An `<ol class="timeline">` lists events in the order the page chooses, newest or oldest first. Each `<li>` is an event: an optional `<time>` with a `datetime`, a heading and supporting `<p>` text. A dot marks each event and a line joins it to the next. A leading icon as the first child replaces the dot with a tonal circle; hide it from assistive technology. CSS only; there is no JavaScript.

```html
<ol class="timeline">
  <li>
    <time datetime="2026-10-06T09:12">9:12 AM</time>
    <h3>Order placed</h3>
    <p>Two items, paid by card.</p>
  </li>
  <li>
    <time datetime="2026-10-06T11:40">11:40 AM</time>
    <h3>Shipped</h3>
    <p>Tracking number sent by email.</p>
  </li>
</ol>
```

### Leading icon

```html
<ol class="timeline">
  <li>
    <span class="material-symbols" aria-hidden="true">chat</span>
    <time datetime="2026-10-06">Today</time>
    <h3>Ada commented</h3>
    <p>Looks good to me.</p>
  </li>
</ol>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-timeline-marker-color` | `primary` |
| `--md-comp-timeline-connector-color` | `outline-variant` |
| `--md-comp-timeline-gap` | 24px |

---

## Calendar

A month, week or day of events, or a year of months, on tables the page renders.

A `table.calendar` has a `<caption>` naming the month, a header row of weekday `<th scope="col">` cells, then a row per week. Each day `<td>` starts with a `<time datetime>` and may hold a `.calendar-events` list of links or text. Mark days from other months with `outside` and today with `aria-current="date"`. The page owns the dates, the navigation between months and the events; CSS draws seven equal columns, the today mark and event chips that ellipsize. On a phone the days get shorter and chips tighter. CSS only; there is no JavaScript. Use the date picker to choose a date for a field.

```html
<table class="calendar">
  <caption>October 2026</caption>
  <thead>
    <tr><th scope="col"><abbr title="Sunday">Sun</abbr></th>…</tr>
  </thead>
  <tbody>
    <tr>
      <td class="outside"><time datetime="2026-09-27">27</time></td>
      <td aria-current="date">
        <time datetime="2026-10-15">15</time>
        <ul class="calendar-events"><li><a href="/events/standup">Standup</a></li></ul>
      </td>
    </tr>
  </tbody>
</table>
```

Add `week` (or `day`, with one day column) for an hourly grid. The header row starts with an empty `<td>` over the hour column, then a `<th scope="col">` per day with the weekday and a `<time>`; mark today's header with `aria-current="date"`. Each body row is an hour named by a `<th scope="row">`. Put an event in the cell for its day and starting hour. It covers one hour; set `--md-comp-calendar-event-duration` (hours) and `--md-comp-calendar-event-start` (hours into the row, `0.5` for half past) inline to change that. The placement is only visual: a screen reader hears the hour row and the title, so write the event's time in a `.visually-hidden` span before its title. Events that overlap in time overlap on screen, and an event that runs past the last hour row spills out of the table, so split overlapping events and end long ones at the last row in the page.

```html
<table class="calendar week">
  <caption>October 11 to 17, 2026</caption>
  <thead>
    <tr>
      <td></td>
      <th scope="col" aria-current="date"><abbr title="Thursday">Thu</abbr> <time datetime="2026-10-15">15</time></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row"><time datetime="14:00">2 PM</time></th>
      <td>
        <ul class="calendar-events">
          <li style="--md-comp-calendar-event-start: 0.5; --md-comp-calendar-event-duration: 2"><a href="/events/planning"><span class="visually-hidden">2:30 to 4:30 PM, </span>Planning</a></li>
        </ul>
      </td>
    </tr>
  </tbody>
</table>
```

For a year, wrap twelve month tables in a `.calendar-year`. The months shrink and fill as many columns as fit. Render only their days; event lists belong in the month, week or day view.

```html
<div class="calendar-year">
  <table class="calendar"><caption>January 2026</caption>…</table>
  <table class="calendar"><caption>February 2026</caption>…</table>
</div>
```

Tokens: `--md-comp-calendar-day-min-height` (96px; 64px Compact), `--md-comp-calendar-hour-height` (48px, week and day), `--md-comp-calendar-outline-color` (`outline-variant`), `--md-comp-calendar-event-container-color` (`primary-container`) and `--md-comp-calendar-event-color` (`on-primary-container`).

---

## Stat

Key figures in tiles, each with a label, a value and an optional change.

A `<dl class="stats">` holds one `<div>` tile per figure: a `<dt>` label, a `<dd>` value and an optional `<dd class="stat-change">` comparison. The description list keeps each value paired with its label for assistive technology. Tiles share a row and wrap below 160px each. CSS only; there is no JavaScript.

Add `up` or `down` to `.stat-change` for an arrow, and `negative` when the change is bad news, which turns it to the error color. The arrow is decoration, so say the direction in the text.

```html
<dl class="stats">
  <div>
    <dt>Revenue</dt>
    <dd>$48.2k</dd>
    <dd class="stat-change up">+12% from last month</dd>
  </div>
  <div>
    <dt>Churn</dt>
    <dd>3.1%</dd>
    <dd class="stat-change up negative">+0.4 points from last month</dd>
  </div>
</dl>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-stat-container-color` | `surface-container-low` |
| `--md-comp-stat-shape` | 16px |
| `--md-comp-stat-min-width` | 160px |

---

## Description list

A record's details as label and value pairs, in two columns that stack on compact screens.

A `dl.description-list` pairs each `<dt>` label with the `<dd>` values after it. Labels take the first column from Medium widths up, with a divider above each pair; at Compact widths, and inside a `.query-container` narrower than 600px, each label sits above its value. A `<div>` may wrap each pair and adds no box. Several `<dd>` elements under one label stack without a divider. Use stat tiles for big numbers instead. CSS only; there is no JavaScript.

```html
<dl class="description-list">
  <dt>Status</dt><dd>Shipped</dd>
  <dt>Ship to</dt><dd>Alvin Lee, 12 Harbor Road, Portland</dd>
  <dt>Total</dt><dd>$87.00</dd>
</dl>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-description-list-label-width` | minmax(8rem, 33%) |
| `--md-comp-description-list-divider-color` | `outline-variant` |
| `--md-comp-description-list-padding` | 12px |

---

## KPI

One key figure in a card, with a trend chip, a sparkline and details.

An `<article class="kpi">` holds a `<dl>` with a `<dt>` label, a `<dd class="kpi-value">` and an optional `<dd class="kpi-trend">` chip. The description list keeps the value paired with its label for assistive technology. The article is a card, so `filled` and `outlined` apply, and KPIs in a grid row stretch to one height with their footers aligned. CSS only; a chart inside is drawn by its own script.

```html
<article class="kpi outlined">
  <dl>
    <dt><span class="kpi-icon material-symbols-outlined" aria-hidden="true">payments</span>Revenue</dt>
    <dd class="kpi-value">$48,200</dd>
    <dd class="kpi-trend up">+12.5%</dd>
  </dl>
  <button type="button" class="icon-button kpi-actions" aria-label="Revenue options">
    <span class="material-symbols-outlined" aria-hidden="true">more_vert</span>
  </button>
  <figure class="line-chart sparkline area">
    <figcaption class="visually-hidden">Revenue over 10 days, in thousands</figcaption>
    <table>
      <thead><tr><th>Day</th><th>Revenue</th></tr></thead>
      <tbody><tr><th>Day 1</th><td>40.1</td></tr><tr><th>Day 2</th><td>42.3</td></tr></tbody>
    </table>
  </figure>
  <div class="kpi-footer">Compared with $42,800 last month</div>
</article>
```

- Add `up` or `down` to `.kpi-trend` for an arrow, and `negative` when the change is bad news. `negative` uses the error container colors and turns the first series of a line, column, bar or mixed chart in the same KPI to the error color. The arrow is decoration, so say the direction in the text.
- `.kpi-icon` inside the `<dt>` draws a 40px tonal icon box. Hide the icon with `aria-hidden="true"`.
- `.kpi-actions` on a button or menu trigger places it in the top end corner. Name it after the figure.
- After the `<dl>`, parts stack in source order: a sparkline chart, a `<progress class="progress">` named with `aria-label`, a `<p>` of supporting text, and a `.kpi-footer` under a full-width divider. The footer sits at the bottom of a stretched card.
- A sparkline is not focusable. Its caption names the measure and period, and its table stays in the page as the text alternative.
- For several figures with no chart or actions, use `dl.stats` instead.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-kpi-icon-container-color` | `primary-container` |
| `--md-comp-kpi-icon-color` | `on-primary-container` |
| `--md-comp-kpi-trend-container-color` | `secondary-container` |
| `--md-comp-kpi-trend-color` | `on-secondary-container` |

The container color, shape and padding come from the card tokens.

---

## Countdown

A number from 0 to 999 whose digits roll to each new value.

A `.countdown` is a `<span>` with `--md-comp-countdown-value` set to a whole number and the same number as its text. When the value changes, each digit rolls up or down to the new one. The rolling digits are generated content hidden from assistive technology, so the text is what a screen reader reads; change the property and the text together. Values below 0 show 0 and values above 999 show 999. The box is as wide as the number; `--md-comp-countdown-digits` pads it with leading zeros to 2 or 3 digits. Size and color come from the surrounding text. With reduced motion the digits change without rolling. CSS only; the page's script sets the value.

```html
<span id="countdown" class="countdown" style="--md-comp-countdown-value: 60">60</span>

<script>
  const countdown = document.getElementById('countdown');
  let value = 60;
  setInterval(() => {
    value = value === 0 ? 60 : value - 1;
    countdown.style.setProperty('--md-comp-countdown-value', value);
    countdown.textContent = value;
  }, 1000);
</script>
```

### Clock

Pad each part to two digits so a clock keeps its width. A `role="timer"` container is not announced on every tick; use `aria-live="polite"` instead when each new value should be read out.

```html
<p role="timer" style="--md-comp-countdown-digits: 2">
  <span class="countdown" style="--md-comp-countdown-value: 9">09</span>:<span
        class="countdown" style="--md-comp-countdown-value: 41">41</span>:<span
        class="countdown" style="--md-comp-countdown-value: 5">05</span>
</p>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-countdown-value` | 0; the number shown, 0 to 999 |
| `--md-comp-countdown-digits` | 1; at least this many digits, padded with zeros |
| `--md-comp-countdown-duration` | 400ms |
| `--md-comp-countdown-easing` | `cubic-bezier(0.2, 0, 0, 1)` |

---

## Line chart

Trends over time drawn from a data table, with a tooltip, a legend and a sparkline size.

A `<figure class="line-chart">` holds a `<figcaption>` and a `<table>`. The first column labels the x axis; each further column is a series named by its header cell. `AutoInit()` draws the table as smooth SVG lines with x labels, a legend when there is more than one series, and a cursor and tooltip that follow the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="line-chart fade">
  <figcaption>Monthly revenue, 2026</figcaption>
  <table>
    <thead>
      <tr><th>Month</th><th>Revenue</th><th class="dashed">Target</th></tr>
    </thead>
    <tbody>
      <tr><th>Jan</th><td>$4,400</td><td>$5,200</td></tr>
      <tr><th>Feb</th><td>$3,400</td><td>$5,400</td></tr>
      <tr><th>Mar</th><td>$3,700</td><td>$5,100</td></tr>
    </tbody>
  </table>
</figure>
```

- `area` fills under each line with a gradient; `stacked` draws each series on the total of the ones before it and fills the band between, with the y axis from zero (keep values positive; an empty cell leaves the total unknown, so that series and the ones above it break at that row, and the tooltip still shows each cell); `points` marks every value with a dot three line widths across; `straight` draws straight lines between the values instead of a smooth curve; `fade` fades the line ends; `sparkline` is a 48px chart with no grid, labels, legend or tooltip, and is not focusable.
- A series header cell's classes reach its line. `<th class="dashed">` draws a dotted line for a target or forecast.
- Series take `primary`, `tertiary`, `secondary` and `on-surface-variant` in turn.
- `toggle` makes each legend item a button with `aria-pressed` that shows or hides its series. The scale refits to the series shown and the others keep their colors. The last series shown cannot be hidden, and a hidden series stays hidden through `update()`. Column, bar, mixed and radar charts take `toggle` too.
- A cell's number is its text without currency signs, percent signs or grouping commas. Text that is not a single number (`4.4k`, `(1,200)`) is a gap, as is an empty cell. Put the number in `data-value` for those, and for `4.400 €`, which would read as 4.4. The tooltip shows the cell text as written.
- The y axis spans the data with 10% to spare. `data-min` and `data-max` on the figure, or the `min` and `max` options, fix either end.
- `axis` labels the y axis at round values beside the plot, with a grid line at each, and widens an end the chart chose to the next round value. The labels repeat the cells' currency or percent sign and shorten large numbers (`$1.5K`); an end set by `data-min` or `data-max` stays put. They are `aria-hidden`; the table carries the values. `--md-comp-line-chart-axis-width` (48px) is the room they take.
- The plot is one Tab stop named by the caption. Left and Right move between rows, Home and End jump to the ends, Escape hides the tooltip. In a right-to-left context the x axis runs right to left and the arrows follow it. The tooltip is a polite live region. Hide a redundant caption with `.visually-hidden` rather than removing it.
- A direct `<figure>` in a card is normally media with an overlaid caption; a `.line-chart` keeps its own layout there.
- After changing the table, call `update()` on the instance to redraw in place; keyboard focus and the highlighted row stay. The lines, areas and points move from their old values to the new ones over `--md-sys-motion-duration-medium4` with `--md-sys-motion-easing-standard`, matched by table column and row position. A series or value new to the chart rises from the plot bottom, or in a stack from the series below; one that goes away goes at once, and so do the axis labels. With reduced motion, or a zero duration token, the new shape appears at once. A second `update()` while the marks move starts them from the values the first one drew, not from where they are. `show(index)` highlights a row (`-1` hides the tooltip) and `destroy()` removes the drawing and shows the table.
- With `data-watch` on the figure, or the `watch` option, the chart calls `update()` itself whenever its table changes: rows or cells added or removed, cell text, or a `data-value`, `class` or heatmap `data-label` inside the table. The edits a script makes in one run redraw it once; edits on either side of an `await` redraw twice. A replaced table is watched too. The attribute is read when the chart starts, and any value turns it on. Changes to the figure itself, such as `data-min`, its classes or its caption, still need `update()`. Every chart type takes `data-watch`.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-line-chart-height` | 240px (sparkline 48px) |
| `--md-comp-line-chart-line-width` | 3px (sparkline 2px) |
| `--md-comp-line-chart-grid-color` | `outline-variant` |
| `--md-comp-line-chart-axis-width` | 48px, room for `axis` labels |
| `--md-comp-line-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Column chart

Values compared across categories or periods, drawn as columns from a data table, grouped or stacked.

A `<figure class="column-chart">` takes the same `<figcaption>` and `<table>` as a line chart. The first column labels each group; each further column is a series named by its header cell. `AutoInit()` draws each row as a group of columns growing from zero, one per series, with x labels, a legend when there is more than one series, and a highlight band and tooltip that follow the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="column-chart">
  <figcaption>Subscriptions by plan</figcaption>
  <table>
    <thead>
      <tr><th>Month</th><th>Basic</th><th>Premium</th></tr>
    </thead>
    <tbody>
      <tr><th>Jan</th><td>5,500</td><td>3,500</td></tr>
      <tr><th>Feb</th><td>6,400</td><td>3,200</td></tr>
      <tr><th>Mar</th><td>7,600</td><td>3,400</td></tr>
    </tbody>
  </table>
</figure>
```

- `stacked` draws one column per row with each series on the total of the ones before it; only the top segment rounds its end. Keep stacked values positive; an empty cell leaves the total unknown, so the segments above it are left out. The tooltip still shows each cell.
- A series header with the class `range` reads each cell as two numbers, such as `120–480`, `−4 – 6` or `3 to 14`, or `data-value="120 480"`, and floats a column from the lower to the higher, rounded at both ends; the scale makes room for low ends below zero. Any other cell is a gap that keeps its slot, and the tooltip shows the cell as written. A range does not stack.
- `fade` fades each column toward its base; `track` draws a full-height track behind each group and rounds both ends of its columns; `sparkline` is a 48px chart with no grid, labels, legend or tooltip, and is not focusable.
- Series take `primary`, `tertiary`, `secondary` and `on-surface-variant` in turn. A series header cell's classes reach its columns as hooks for your own styles; `dashed` has no column style.
- Cells read as in a line chart: currency signs, percent signs and grouping commas are ignored, other text is a gap, and `data-value` gives the number. In a grouped chart a gap leaves an empty slot.
- The y axis always includes zero, with 10% of the span to spare above and, with negative values, below. Negative columns hang from zero and round their lower end. `data-min` and `data-max` on the figure, or the `min` and `max` options, fix either end; when zero is off the scale, columns start at the plot's bottom.
- `axis` labels the y axis at round values beside the plot, with a grid line at each, and widens an end the chart chose to the next round value. The labels repeat the cells' currency or percent sign and shorten large numbers (`$1.5K`); an end set by `data-min` or `data-max` stays put. They are `aria-hidden`; the table carries the values. `--md-comp-column-chart-axis-width` (48px) is the room they take.
- Keyboard, RTL, the live tooltip and cards behave as for the line chart: the plot is one Tab stop named by the caption, and a `.column-chart` in a card keeps its own layout.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. The columns move from their old values to the new ones with their usual transition, and a new column grows from zero, or in a stack from the segment below; with reduced motion they appear at once. `show(index)` highlights a group (`-1` hides the tooltip) and `destroy()` removes the drawing and shows the table.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-column-chart-height` | 240px (sparkline 48px) |
| `--md-comp-column-chart-column-width` | 60% of each group's width |
| `--md-comp-column-chart-shape` | 4px |
| `--md-comp-column-chart-track-color` | `surface-container-highest` |
| `--md-comp-column-chart-grid-color` | `outline-variant` |
| `--md-comp-column-chart-axis-width` | 48px, room for `axis` labels |
| `--md-comp-column-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Bar chart

Ranked or long-labelled categories drawn as horizontal bars from a data table, grouped, stacked or as a meter.

A `<figure class="bar-chart">` takes the same `<figcaption>` and `<table>` as a column chart. `AutoInit()` draws each row as a band of bars growing from zero along the inline axis, one bar per series, with the row's label at the inline start, a legend when there is more than one series, and a highlight band and tooltip that follow the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="bar-chart track values">
  <figcaption>Visitors by source</figcaption>
  <table>
    <thead>
      <tr><th>Source</th><th>Visitors</th></tr>
    </thead>
    <tbody>
      <tr><th>Search</th><td data-value="900000">900K</td></tr>
      <tr><th>Social</th><td data-value="650000">650K</td></tr>
      <tr><th>Email</th><td data-value="420000">420K</td></tr>
    </tbody>
  </table>
</figure>
```

- The plot is as tall as its rows: `--md-comp-bar-chart-row-height` sets each row, so a longer table makes a taller chart. Labels take up to 40% of the width and end in an ellipsis past it.
- `stacked` draws one bar per row with each series after the total of the ones before it; only the last segment rounds its end. `split` separates stacked segments with a gap and rounds them at each joint. With `track` and `data-max` set to a budget, one stacked row is a meter of what is spent and what is left.
- A series header with the class `range` reads each cell as two numbers, as in the column chart, and floats a bar between them, rounded at both ends.
- `track` draws a full-width track behind each row and rounds both ends of its bars. `values` writes each cell's text from its bar's base, in the series' on-color over the bar; a value longer than a short bar runs on past its end in the label color, and a stacked segment cuts what does not fit. `sparkline` drops the labels, grid, legend and tooltip and is not focusable.
- Cells, series colors, `data-min`, `data-max`, negative values and gaps work as in the column chart; negative bars grow toward the inline start.
- `axis` labels the value axis at round values under the plot, with a grid line up from each, as in the column chart.
- The plot is one Tab stop named by the caption. Up and down arrows move between rows, Home and End jump to the ends and Escape hides the tooltip; a tap shows the tapped row. In a right-to-left page, set by `dir` or CSS `direction`, the bars grow leftwards and the labels sit on the right; rows keep their order.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. The bars move from their old values to the new ones, as the column chart's columns do; with reduced motion they appear at once. It takes the column chart's options and methods.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-bar-chart-row-height` | 40px (sparkline 24px) |
| `--md-comp-bar-chart-bar-height` | 60% of each row's height |
| `--md-comp-bar-chart-shape` | 4px |
| `--md-comp-bar-chart-track-color` | `surface-container-highest` |
| `--md-comp-bar-chart-grid-color` | `outline-variant` |
| `--md-comp-bar-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |
| `--md-comp-bar-chart-on-color-1` … `-4` | `on-primary`, `on-tertiary`, `on-secondary`, `surface` |

---

## Pie chart

Parts of a whole drawn as slices from a data table, as a pie or a donut with its total.

A `<figure class="pie-chart">` takes the same `<figcaption>` and `<table>` as a column chart. `AutoInit()` draws the first value column as slices of the total, clockwise from the top in table order, with a legend of every row, its cell and its share, and a tooltip that follows the pointer round the pie. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="pie-chart donut">
  <figcaption>Lead sources, last six months</figcaption>
  <table>
    <thead>
      <tr><th>Source</th><th>Leads</th></tr>
    </thead>
    <tbody>
      <tr><th>Referrals</th><td>558</td></tr>
      <tr><th>Organic search</th><td>434</td></tr>
      <tr><th>Paid campaigns</th><td>248</td></tr>
    </tbody>
    <tfoot>
      <tr><th>Total leads</th><td>1,240</td></tr>
    </tfoot>
  </table>
</figure>
```

- The plot is a square as wide as `--md-comp-pie-chart-height`, centered in the figure; the legend lists the rows below it. Shares are whole percentages, with a tenth below 10%, in the page's `lang`.
- `donut` cuts a hole sized by `--md-comp-pie-chart-hole`. A `<tfoot>` row is the total: its value and label show in the hole, and it is not a slice. `values` writes each share on its slice in the slice's on-color; slices under 5% get no label. `labels` writes each slice's row name outside it, running away from the pie; names that would touch move apart up or down their side, a line leads from each name back to its slice, and the pie shrinks to leave `--md-comp-pie-chart-label-width`, or a quarter of a narrow figure, on each side. `sparkline` is a 48px pie with no legend, tooltip or names and is not focusable.
- `data-other="5"` groups every row under 5% of the whole into one last row, named by `data-other-label` ("Other" by default), with the rows' sum and share; the sum takes the cells' signs and decimals, and is compact when they use `data-value`. Further columns leave the group out. Rows with no value keep their place, a single small row keeps its own slice, and the table keeps every row.
- Cells read as in the column chart. Zero, negative and non-numeric cells draw no slice and have no share; their rows stay in the legend and tooltip. Further value columns add lines to the tooltip. Slices take six colors; a seventh row takes the second color so it does not match the first beside it.
- The plot is one Tab stop named by the caption. Right and down arrows move to the next row, left and up to the one before, Home and End jump to the ends and Escape hides the tooltip; a tap shows the tapped slice. In a right-to-left page, set by `dir` or CSS `direction`, the slices still run clockwise and left and right swap.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. It has the line chart's `show(index)` and `destroy()`; the `min` and `max` options do not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-pie-chart-height` | 240px, the diameter (sparkline 48px) |
| `--md-comp-pie-chart-hole` | 0.6 of the diameter |
| `--md-comp-pie-chart-label-width` | 96px on each side, for `labels`; at most a quarter of the figure |
| `--md-comp-pie-chart-gap` | 1.5, in hundredths of the plot's width |
| `--md-comp-pie-chart-color-1` … `-6` | `primary`, `tertiary`, `secondary`, `on-surface-variant`, `primary-container`, `tertiary-container` |
| `--md-comp-pie-chart-on-color-1` … `-6` | `on-primary`, `on-tertiary`, `on-secondary`, `surface`, `on-primary-container`, `on-tertiary-container` |

---

## Heatmap chart

A data table drawn as a grid of cells shaded by value, for patterns across two categories such as days and hours.

A `<figure class="heatmap-chart">` takes the same `<figcaption>` and `<table>` as a column chart. `AutoInit()` draws every body cell as a cell of a grid, one grid row per table row and one grid column per value column, shaded from the lowest value to the highest. The row headers sit at the inline start, the column headers below, and a scale under the grid names the lowest and highest cells. A tooltip shows the cell under the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="heatmap-chart values">
  <figcaption>Revenue by rep, per month</figcaption>
  <table>
    <thead>
      <tr><th>Rep</th><th>Jan</th><th>Feb</th><th>Mar</th></tr>
    </thead>
    <tbody>
      <tr><th>Priya</th><td data-value="45000">$45K</td><td data-value="58000">$58K</td><td data-value="85000">$85K</td></tr>
      <tr><th>Daniel</th><td data-value="88000">$88K</td><td data-value="34000">$34K</td><td data-value="45000">$45K</td></tr>
    </tbody>
  </table>
</figure>
```

- Each row is `--md-comp-heatmap-chart-row-height` tall and the columns share the width down to `--md-comp-heatmap-chart-cell-min-width` each; past that the cells scroll sideways while the row labels stay in view, and the arrow keys scroll the cell they move to into view. Row labels take up to 40% of the width and end in an ellipsis past it. Cells mix `--md-comp-heatmap-chart-color` into `--md-comp-heatmap-chart-empty-color` by their place on the scale.
- `values` writes each cell's text on it, in black or white, whichever has more WCAG contrast with the shade. `data-label` on a header cell replaces its text on the grid; an empty one hides it, and a column label runs on over the empty labels after it. The table and the tooltip keep the full header.
- The scale runs from the lowest value to the highest; `data-min` and `data-max` on the chart, or the `min` and `max` options, fix it, and values past either end take the end's shade. The scale under the grid runs from the lowest cell's shade to the highest's, labelled with their text, so a fixed scale wider than the data shows only the part it uses. `data-mid="0"` makes the scale diverge from that value: the midpoint is the empty color, values above shade towards `--md-comp-heatmap-chart-color` and values below towards `--md-comp-heatmap-chart-low-color`, as far as the end further from the midpoint. `data-steps="4"` groups the shades into that many bands (each side, on a diverging scale), with the lowest band in the empty color, and the scale becomes a swatch per band.
- Cells read as in the column chart. A non-numeric cell is a gap: outlined, with no shade, and its text still shows in the tooltip.
- The plot is one Tab stop named by the caption. The arrow keys move one cell the way they point, Home and End jump to the ends of the row and Escape hides the tooltip; with the tooltip hidden, Escape is left to a surrounding dialog. A tap or a drag shows the cell under the finger, and the space between cells counts as the nearer cell. In a right-to-left page, set by `dir` or CSS `direction`, the columns run right to left and left and right swap. In forced colors the cells keep their shades.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. It has the line chart's `show(index)` and `destroy()`; `show()` takes a cell, counted row by row from 0.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-heatmap-chart-row-height` | 32px |
| `--md-comp-heatmap-chart-cell-min-width` | 12px; below it a wide grid scrolls |
| `--md-comp-heatmap-chart-gap` | 3px between cells |
| `--md-comp-heatmap-chart-shape` | 4px, the cell's corner radius |
| `--md-comp-heatmap-chart-color` | `primary`, the highest value's shade |
| `--md-comp-heatmap-chart-low-color` | `tertiary`, the lowest value's shade on a diverging scale |
| `--md-comp-heatmap-chart-empty-color` | `surface-container-highest`, the lowest value's shade, or the midpoint's |
| `--md-comp-heatmap-chart-grid-color` | `outline-variant`, a gap's outline |

---

## Radar chart

Several measures on one scale drawn as shapes on spokes from a data table, one shape per series.

A `<figure class="radar-chart">` takes the same `<figcaption>` and `<table>` as a line chart. `AutoInit()` draws each row as a spoke, clockwise from the top in table order, with its label past the tip, and each value column as a shape joining its values, from zero in the middle to the scale's top at the rim. A legend names the series when there is more than one, and a tooltip follows the pointer round the chart. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="radar-chart area" data-max="100">
  <figcaption>Client satisfaction</figcaption>
  <table>
    <thead>
      <tr><th>Measure</th><th>This quarter</th><th class="dashed">Last quarter</th></tr>
    </thead>
    <tbody>
      <tr><th>Communication</th><td>92</td><td>80</td></tr>
      <tr><th>Quality</th><td>85</td><td>76</td></tr>
      <tr><th>Timeliness</th><td>78</td><td>82</td></tr>
      <tr><th>Value</th><td>88</td><td>74</td></tr>
      <tr><th>Support</th><td>81</td><td>70</td></tr>
    </tbody>
  </table>
</figure>
```

- The plot is a square as wide as `--md-comp-radar-chart-height`, centered with `--md-comp-radar-chart-label-room` on each side for the labels. Four grid rings split the scale evenly.
- `area` fills each shape, `points` marks each value, and `class="dashed"` on a header cell dashes that series' outline. `sparkline` is a 48px chart with no labels, legend or tooltip and is not focusable.
- Cells read as in the line chart. A non-numeric cell is a gap: the outline breaks there and the tooltip leaves it out; `area` fills each unbroken run as a wedge from the middle, and a value between two gaps is a dot. The middle is zero, or below the lowest value if one is negative; the rim is the highest value plus a tenth of the range. `data-min` and `data-max`, or the `min` and `max` options, fix the scale; values past the rim stop at it. Series take four colors.
- `axis` draws the grid rings at round values instead of quarters and labels each ring up the top spoke, leaving zero unlabelled; the rim widens to the next round value unless `data-max` sets it.
- The plot is one Tab stop named by the caption. Right and down arrows move to the next row, left and up to the one before, wrapping between the last row and the first; Home and End jump to the ends and Escape hides the tooltip; the pointer or a tap shows the nearest spoke. In a right-to-left page the spokes and the arrows still run clockwise.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. It has the line chart's `show(index)` and `destroy()`.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-radar-chart-height` | 240px, the rim's diameter (sparkline 48px) |
| `--md-comp-radar-chart-line-width` | 2px (sparkline 1.5px) |
| `--md-comp-radar-chart-label-room` | 80px, the labels' width beside the rim |
| `--md-comp-radar-chart-grid-color` | `outline-variant` |
| `--md-comp-radar-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Radial chart

Progress toward a goal drawn as rings from a data table, as concentric rings or a half-circle gauge.

A `<figure class="radial-chart">` takes the same `<figcaption>` and `<table>` as a column chart. `AutoInit()` draws each row of the first value column as a ring, outermost first, filled clockwise from the top as a share of `data-max` (100 by default), with a legend of the rows and their cells when there is more than one, and a tooltip that follows the pointer across the rings. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="radial-chart gauge" data-max="25">
  <figcaption>Team capacity</figcaption>
  <table>
    <thead>
      <tr><th>Seats</th><th>Count</th></tr>
    </thead>
    <tbody>
      <tr><th>Filled</th><td>18</td></tr>
      <tr><th>Offered</th><td>21</td></tr>
    </tbody>
    <tfoot>
      <tr><th>Total seats</th><td>25</td></tr>
    </tfoot>
  </table>
</figure>
```

- The plot is a square as wide as `--md-comp-radial-chart-height`, centered in the figure; the legend lists the rows below it. Each ring has a track behind it in `--md-comp-radial-chart-track-color`; `transparent` hides the tracks.
- A `<tfoot>` row shows in the middle, its value over its label, and is not a ring. `gauge` draws half circles over the middle, filling from the left, with the total on the base line. `labels` draws the rings three quarters of the way round, from the top to the left, and writes each row's name in the empty top left quarter beside its ring's start; names shrink to fit crowded rings. On a gauge, the names hang under the base line, a row per ring with the innermost on top, each after a line down from its ring's start; a sparkline ignores it. `sparkline` is a 48px chart with no legend or tooltip and is not focusable.
- `data-segments="10"` splits each ring into that many segments, with the ring gap between them, and fills whole segments to the one nearest the value; under half a segment draws none. `gradient` shades each fill along its ring, from a light tint at the start to the full color where the fill ends: a conic gradient in HTML, masked to the fill's dash, over the SVG fill.
- Cells read as in the column chart. `data-max` must be above zero; otherwise the `max` option or 100 applies. Values past it close the ring; zero, negative and non-numeric cells leave only the track, and their rows stay in the legend and tooltip. Further value columns add lines to the tooltip. Rings take four colors, then repeat. Rings and their gaps thin together when there are too many to leave a middle.
- The plot is one Tab stop named by the caption. Down and right arrows move to the next ring inwards, up and left to the one before, wrapping between the innermost ring and the outermost; Home and End jump to the ends and Escape hides the tooltip; a tap shows the tapped ring. In a right-to-left page the rings and the arrows still run clockwise.
- After changing the ring tokens, or the table without `data-watch`, call `update()` on the instance to redraw in place; keyboard focus and the highlighted row stay. It has the line chart's `show(index)` and `destroy()`; the `max` option is the maximum without `data-max`, and `min` does not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-radial-chart-height` | 240px, the diameter (sparkline 48px) |
| `--md-comp-radial-chart-thickness` | 8, a ring's width in hundredths of the diameter |
| `--md-comp-radial-chart-gap` | 2, between rings, in the same units |
| `--md-comp-radial-chart-track-color` | `surface-container-highest` |
| `--md-comp-radial-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Mixed chart

Columns, lines and areas together from one data table, with a second scale for values in other units.

A `<figure class="mixed-chart">` takes the same `<figcaption>` and `<table>` as a line chart. `AutoInit()` draws each series by its header cell's class: `column` as columns from zero, `area` as a line over a fading fill, and any other series as a line, with `dashed` for a dashed line. Columns sit behind the lines. A legend names the series and a tooltip follows the pointer from row to row. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="mixed-chart" data-end-min="0" data-end-max="35">
  <figcaption>Revenue ($K), profit margin and customer growth</figcaption>
  <table>
    <thead>
      <tr>
        <th>Month</th>
        <th class="column">Revenue</th>
        <th class="area end">Profit margin</th>
        <th class="dashed end">Customer growth</th>
      </tr>
    </thead>
    <tbody>
      <tr><th>Jan</th><td>$420</td><td>18%</td><td>4%</td></tr>
      <tr><th>Feb</th><td>$460</td><td>19%</td><td>5%</td></tr>
    </tbody>
  </table>
</figure>
```

- Series share one scale. A series whose header has `end` is drawn against a second scale fitted to the `end` series, or fixed by `data-end-min` and `data-end-max` on the figure, as `data-min` and `data-max` fix the first. A scale with columns always includes zero. Without `axis` there is no y axis, so name the units in the caption or headers.
- `axis` labels the first scale beside the plot's start, with grid lines, and the `end` scale along its end. When neither scale has a set end, the `end` scale stretches to as many round steps as the first so its labels share the grid lines. Each scale's labels repeat its cells' currency or percent sign.
- Cells read as in the line chart; a suffix such as `K` makes a cell a gap, so use `data-value` or put the unit in the header. A gap breaks a line and leaves a column's slot empty. Series take four colors in column order, whatever their kind, then repeat.
- The cursor bands the highlighted row and puts a point on each line and area. The plot is one Tab stop named by the caption; left and right move between rows, Home and End jump to the ends and Escape hides the tooltip. `sparkline` is a 48px chart with no grid, labels, legend or tooltip and is not focusable.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. Its columns, lines and areas move from their old values to the new ones, as in the line and column charts; with reduced motion they appear at once. It has the line chart's `show(index)` and `destroy()`; the `min` and `max` options apply to the first scale.
- `stacked` stacks the column series into one column per row; the `end` series' columns stack in their own column beside it, so list them after the others. Lines and areas keep their own values, and the tooltip shows each cell. `points` marks every value on the lines and areas with a dot, and `straight` draws straight lines instead of curves.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-mixed-chart-height` | 240px (sparkline 48px) |
| `--md-comp-mixed-chart-column-width` | 50% of each row's width |
| `--md-comp-mixed-chart-shape` | 4px, the column's end radius |
| `--md-comp-mixed-chart-line-width` | 3px (sparkline 2px) |
| `--md-comp-mixed-chart-grid-color` | `outline-variant` |
| `--md-comp-mixed-chart-axis-width` | 48px, room on each side for `axis` labels |
| `--md-comp-mixed-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Scatter chart

One measure plotted against another, with a point for each row.

A `<figure class="scatter-chart">` takes a `<figcaption>` and a `<table>`. The first column labels each row, the second holds its x value, and each further column is a series of y values named by its header cell. `AutoInit()` draws one point per series for each row, at the row's x value and the series' y value. A legend names the series when there are two or more, and a tooltip shows the point under the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="scatter-chart axis" data-x-min="0">
  <figcaption>Fuel economy by weight</figcaption>
  <table>
    <thead>
      <tr><th>Car</th><th>Weight (kg)</th><th>City mpg</th><th>Highway mpg</th></tr>
    </thead>
    <tbody>
      <tr><th>Civic</th><td>1300</td><td>31</td><td>40</td></tr>
      <tr><th>Camry</th><td>1600</td><td>28</td><td>39</td></tr>
      <tr><th>Tahoe</th><td>2600</td><td>15</td><td>20</td></tr>
    </tbody>
  </table>
</figure>
```

- Cells read as in the line chart: `data-value` wins over the text, and the text may carry a currency or percent sign and grouping commas around one number. A row whose x cell is not a number draws no points, and a y cell that is not a number draws no point for that series. Series share the x column, so a series that needs other x values puts them in rows of their own with the other series' cells left empty.
- Both scales fit the points with 10% to spare. `data-min` and `data-max` on the figure, or the `min` and `max` options, fix the y scale's ends, and `data-x-min` and `data-x-max`, or the `xMin` and `xMax` options, fix the x scale's. x grows toward the inline end, so it runs leftward in a right-to-left page.
- `axis` labels the y scale beside the plot's start and the x scale below it, at round steps with a grid line at each. Each scale's labels repeat its cells' currency or percent sign. Without `axis` there are no labels, so name the units in the caption or headers.
- The plot is one Tab stop named by the caption. Keyboard focus shows the first point; the arrow keys step through the points in table order, row by row and within a row by series, skipping the points left out. Home and End jump to the first and last point and Escape hides the tooltip. The arrows keep their meaning in a right-to-left page. The pointer shows the point nearest to it.
- The tooltip names the row, the x header with its cell, and the series with its cell, as written in the table. A larger dot marks the highlighted point.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted point stay. Points move from their old places to the new ones; with reduced motion they appear at once. `toggle` makes each legend item a button that shows or hides its series, and both scales refit. It has the line chart's `show(index)`, which takes a point's number in table order, and `destroy()`.
- `bubble` makes the last column each row's size: it is no series, and every point in the row becomes a bubble whose area follows that size. The largest size drawn is `--md-comp-scatter-chart-bubble-size` across, and the rest scale by area against it, refitting when a `toggle` legend hides a series. A row whose size is not a positive number draws no points. The tooltip adds the size header and cell, the marker outlines the bubble, and bubbles grow or shrink from their old sizes after `update()`. Bubbles are partly transparent so overlaps show, smaller ones drawn over larger ones in each series. The chart leaves half the largest diameter of room around the plot and moves the axis labels out by as much, so a bubble at the end of a scale stays clear of the labels, caption and legend. The pointer still picks the nearest centre, so inside a large bubble it can pick a smaller one nearby.
- Series take four colors in column order, then repeat. Lines, `stacked`, `straight`, `points`, `values` and `sparkline` do not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-scatter-chart-height` | 240px |
| `--md-comp-scatter-chart-point-size` | 8px |
| `--md-comp-scatter-chart-bubble-size` | 48px, the largest `bubble` diameter |
| `--md-comp-scatter-chart-grid-color` | `outline-variant` |
| `--md-comp-scatter-chart-axis-width` | 48px, room for the y `axis` labels |
| `--md-comp-scatter-chart-color-1` … `-4` | `primary`, `tertiary`, `secondary`, `on-surface-variant` |

---

## Sankey chart

Flows between nodes, each link as thick as its value.

A `<figure class="sankey-chart">` takes a `<figcaption>` and a `<table>` with one row per flow. The first column names the flow's source node, the second its target node, and the third holds its value. `AutoInit()` draws the nodes in columns and each row as a link from its source to its target, and a tooltip shows the link under the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="sankey-chart">
  <figcaption>Energy flow (TWh)</figcaption>
  <table>
    <thead>
      <tr><th>Source</th><th>Target</th><th>TWh</th></tr>
    </thead>
    <tbody>
      <tr><th>Solar</th><td>Grid</td><td>30</td></tr>
      <tr><th>Wind</th><td>Grid</td><td>50</td></tr>
      <tr><th>Grid</th><td>Homes</td><td>60</td></tr>
      <tr><th>Grid</th><td>Industry</td><td>20</td></tr>
    </tbody>
  </table>
</figure>
```

- A node is named by its cell's text, so the same name in either column is the same node. The value cell reads as in the line chart: `data-value` wins over the text, and the text may carry a currency or percent sign and grouping commas around one number. Columns after the third are not drawn.
- A row draws nothing when its value is not a positive number, when its source or target cell is empty, when its source and target are the same node, or when its link would close a cycle with the rows above it. The other links and nodes draw as if that row were absent.
- A node with no incoming link stands in the first column, and any other node one column past the furthest of its sources. The columns spread from the inline start to the inline end, so they run leftward in a right-to-left page. Within a column, nodes stack in the order their names first appear in the table, so sort the rows to change the order or to cut crossings.
- Each link is as thick as its value, and each node as tall as the larger of its incoming and outgoing totals, on one scale that fits the tallest column to the plot. A node's links leave and arrive in table order down its edges.
- Nodes take six colors in node order, then repeat, and each link takes its source node's color, lighter. Each node's name sits beside it, toward the plot's middle.
- The plot is one Tab stop named by the caption. Keyboard focus shows the first link; the arrow keys step through the drawn links in table order, right and down to the next and left and up to the one before, skipping the rows that draw nothing. Home and End jump to the first and last link and Escape hides the tooltip. The arrows keep their meaning in a right-to-left page. The pointer shows the link under it. Nodes are not reached by keys or pointer; their totals are in the table.
- The tooltip names the source and target under their column headers and the value cell as written, and the shown link darkens.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted link stay. Links and nodes still in the table move from their old shapes to the new ones; with reduced motion they appear at once. It has the line chart's `show(index)`, which takes a link's number among those drawn, and `destroy()`.
- `axis`, `toggle`, `stacked`, `values` and `sparkline` do not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-sankey-chart-height` | 240px |
| `--md-comp-sankey-chart-link-opacity` | 0.4 |
| `--md-comp-sankey-chart-color-1` … `-6` | `primary`, `tertiary`, `secondary`, `on-surface-variant`, then `primary-container` and `tertiary-container` deepened toward `on-surface` |

---

## Funnel chart

Steps people take in order, each drawn as a stage as wide as the people who reach it.

A `<figure class="funnel-chart">` takes a `<figcaption>` and a `<table>`. The first column labels each row and the second holds its value. `AutoInit()` draws each row as a stage, from the top down in table order, centered and as wide as its value against the largest one, which fills the chart. Each row's label sits beside its stage at the inline start. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="funnel-chart">
  <figcaption>Signups this month</figcaption>
  <table>
    <thead>
      <tr><th>Step</th><th>People</th><th>Revenue</th></tr>
    </thead>
    <tbody>
      <tr><th>Visited</th><td>12,000</td><td>$0</td></tr>
      <tr><th>Signed up</th><td>4,800</td><td>$0</td></tr>
      <tr><th>Paid</th><td>640</td><td>$19,200</td></tr>
    </tbody>
  </table>
</figure>
```

- Cells read as in the line chart: `data-value` wins over the text, and the text may carry a currency or percent sign and grouping commas around one number. A row whose value is not a number, or is zero or less, draws no stage but keeps its place and its label. Stages follow the table's order and are not sorted.
- Only the second column draws stages, all in `--md-comp-funnel-chart-color-1`. Further columns show in the tooltip, so there is no legend.
- The plot is one Tab stop named by the caption. Keyboard focus shows the first row; the down and up arrows move between rows, Home and End jump to the first and last, and Escape hides the tooltip. The keys are the same in a right-to-left page, where the labels move to the right. The pointer shows the row under it.
- The tooltip names the row and each column's header and cell, as written in the table. After the second column's text it adds the stage's value as a percentage of the first row's, such as "4,800 (40%)": whole percentages, with a tenth below 10%, in the page's language. A row without a stage, or a first row without one, has no percentage.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. Each stage grows or shrinks from its old width, and a new one grows from nothing; with reduced motion they take their widths at once. It has the line chart's `show(index)` and `destroy()`.
- `axis`, `toggle`, `stacked`, `values`, `sparkline`, `data-min` and `data-max` do not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-funnel-chart-row-height` | 40px |
| `--md-comp-funnel-chart-stage-height` | 60% of each row's height |
| `--md-comp-funnel-chart-shape` | 4px, the stage's corner radius |
| `--md-comp-funnel-chart-color-1` | `primary`, every stage's color |

---

## Treemap chart

Parts of a whole drawn as rectangles whose areas follow their values, filling the plot.

A `<figure class="treemap-chart">` takes the same `<figcaption>` and `<table>` as a pie chart. `AutoInit()` draws each row of the first value column as a rectangle whose share of the plot's area is its share of the total, with its name and cell written inside it, and a tooltip that follows the pointer. The table stays in the page, visually hidden, as the chart's text alternative; without JavaScript it shows instead of the chart.

```html
<figure class="treemap-chart">
  <figcaption>Budget by team</figcaption>
  <table>
    <thead>
      <tr><th>Team</th><th>Budget</th></tr>
    </thead>
    <tbody>
      <tr><th>Engineering</th><td>$600,000</td></tr>
      <tr><th>Research</th><td>$300,000</td></tr>
      <tr><th>Design</th><td>$120,000</td></tr>
      <tr><th>Sales</th><td>$80,000</td></tr>
    </tbody>
  </table>
</figure>
```

- The layout is squarified: rows are placed from the largest value to the smallest, equal values in table order, each strip chosen to keep its rectangles near square for the plot's shape. The largest starts at the top inline-start corner, so a right-to-left page, set by `dir` or CSS `direction`, mirrors the layout. A rectangle's place depends on its value, not its row, and the layout follows the plot when it changes shape.
- Cells read as in the column chart. Zero, negative and non-numeric cells draw no rectangle and add nothing to the total; their rows stay in the table. Further value columns add lines to the tooltip.
- Each rectangle shows its row name and its cell, as written, when both fit; one too small for them shows neither, and the tooltip names it. Long names end in an ellipsis. Rectangles take six colors in table order, then repeat, with text in each color's on-color. `--md-comp-treemap-chart-gap` parts neighbours inside each rectangle's area, so the areas stay exact.
- The plot is one Tab stop named by the caption. Keyboard focus shows the first row with a rectangle. Right and down arrows move to the next such row in table order and left and up to the one before; the arrows keep their meaning in a right-to-left page. Home and End jump to the first and last, and Escape hides the tooltip. A tap or the pointer shows the rectangle under it.
- The tooltip names the row, the value header with its cell and the row's share of the whole, in whole percentages with a tenth below 10%, in the page's `lang`. A ring in the gap marks the highlighted rectangle.
- After changing the table, call `update()` on the instance to redraw in place, or start the chart with `data-watch`; keyboard focus and the highlighted row stay. Rectangles take their new places at once. It has the line chart's `show(index)`, which passes a row with no rectangle to the next one with one, and `destroy()`. There is no legend, axis or `sparkline`, and the `min` and `max` options do not apply.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-treemap-chart-height` | 240px |
| `--md-comp-treemap-chart-gap` | 4px between neighbours |
| `--md-comp-treemap-chart-color-1` … `-6` | `primary`, `tertiary`, `secondary`, `on-surface-variant`, then `primary-container` and `tertiary-container` deepened toward `on-surface` |
| `--md-comp-treemap-chart-on-color-1` … `-6` | `on-primary`, `on-tertiary`, `on-secondary`, `surface`, `surface`, `surface` |

---

## Tree

Nested lists of folders and links that open in place, for file browsers and deep navigation.

A `<ul class="tree">` nests lists. A branch is an `<li>` holding a `<details>`: its `<summary>` is the row and its nested `<ul>` the children. A leaf is an `<li>` holding a link, or a `<span>` when it is not one. Mark the current row with `aria-current`. Each level draws a guide line from its parent's chevron. Wrap a tree of links in a named `<nav>`. CSS only; there is no JavaScript.

It is lists of disclosures and links rather than `role="tree"`: every row is a Tab stop and the browser opens a branch with Enter or Space. A tree role would promise arrow-key navigation that native disclosures do not provide.

```html
<nav aria-label="Files">
  <ul class="tree">
    <li>
      <details open>
        <summary>src</summary>
        <ul>
          <li><a href="/src/index.ts" aria-current="page">index.ts</a></li>
          <li>
            <details>
              <summary>components</summary>
              <ul>
                <li><a href="/src/components/menu.ts">menu.ts</a></li>
              </ul>
            </details>
          </li>
        </ul>
      </details>
    </li>
    <li><a href="/README.md">README.md</a></li>
  </ul>
</nav>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-tree-row-height` | 40px |
| `--md-comp-tree-row-shape` | 100px |
| `--md-comp-tree-guide-color` | `outline-variant` |
| `--md-comp-tree-selected-container-color` | `secondary-container` |

---

## Organization chart

A reporting line drawn top down, with each person as a card under their manager.

A `<ul class="org-chart">` nests lists, one `<li>` per person. A person with reports holds a `<details open>`: its `<summary class="org-chart-node">` is the card and its nested `<ul>` the reports, so the browser folds a team away. A person without reports holds a `<div class="org-chart-node">`, or an `<a class="org-chart-node">` to their profile. A card stacks an optional `.org-chart-role` band, which must come first, an `.avatar` and an `.org-chart-name`. The stylesheet draws the connecting lines. CSS only.

```html
<ul class="org-chart" aria-label="Company staff">
  <li>
    <details open>
      <summary class="org-chart-node">
        <span class="org-chart-role">CIO</span>
        <img class="avatar large" src="marisol.jpg" alt="">
        <span class="org-chart-name">Marisol Okafor</span>
      </summary>
      <ul>
        <li>
          <div class="org-chart-node">
            <span class="org-chart-role">R&amp;D</span>
            <span class="avatar large" aria-hidden="true">IH</span>
            <span class="org-chart-name">Ingrid Halvorsen</span>
          </div>
        </li>
      </ul>
    </details>
  </li>
</ul>
```

- Name the root list with `aria-label`. The nested lists carry the hierarchy; the lines say nothing.
- Give an avatar image `alt=""`, and an initials avatar `aria-hidden="true"`, when the name follows it.
- Keep a manager's card a `<summary>` and put no link inside it. Only a card without reports can be a link.
- No `role="tree"`: like `.tree`, the chart is native disclosures, with each manager a Tab stop.
- Siblings sit side by side, so a wide chart scrolls sideways inside the root list. Right-to-left pages mirror the order.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-org-chart-line-color` | `outline-variant` |
| `--md-comp-org-chart-line-length` | 24px; each vertical connector |
| `--md-comp-org-chart-gap` | 16px; between siblings |
| `--md-comp-org-chart-node-container-color` | `surface-container-high` |
| `--md-comp-org-chart-node-shape` | 12px |
| `--md-comp-org-chart-role-container-color` | `primary-container` |
| `--md-comp-org-chart-role-color` | `on-primary-container` |

---

## Auto Init

Initialize every registered component with one function call.

Auto Init starts all of the registered Expressive components with a single call. The IIFE bundle exposes it as `Expressive.AutoInit`.

Importing the JavaScript installs a few document-level behaviors (Forms, Chips, Slider, Cards, ExpandingCard, Dialogs, BottomSheets, and SideSheets), but it does **not** call `AutoInit()` for you. Call it after the DOM is ready. This documentation site does that in `docs.js` on `DOMContentLoaded`.

### Initialization

```js
document.addEventListener('DOMContentLoaded', function() {
  Expressive.AutoInit();
});
```

Or pass a root element and per-component options. The option keys are the component names from the table below.

```js
Expressive.AutoInit(document.body, {
  Menu: {
    // pass options for Menu here
  },
  FloatingActionButton: {
    // pass options for FloatingActionButton here
  }
});
```

From the module build:

```js
import { AutoInit, Tooltip } from './js/expressive.mjs';

AutoInit();

const element = document.querySelector('.custom-tooltip');
Tooltip.init(element, { position: 'top' });
```

### Options

`AutoInit(context, options)` takes two arguments:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `context` | Element | `document.body` | DOM element to search within for components. |
| `options` | Object | `{}` | Options for each component, keyed by name. See that component’s page for its own options. |

Calling `AutoInit()` again on the same elements is safe: each component’s constructor destroys any existing instance on that element before creating a new one.

### Components

These are the components `AutoInit()` starts, and the selector each one claims. Elements that also have `no-autoinit` are skipped.

| Name | Selector |
| --- | --- |
| `AppBar` | `header.medium, header.large, header:has(> :is(nav:not(.tabs, .navigation-bar, .navigation-rail), .bar) > .search-bar)` |
| `Autocomplete` | `.autocomplete` |
| `ButtonGroup` | `.button-group` |
| `Cards` | `article:has(> aside[id]:not([id=""])):not(:has(> aside ~ aside)):has(> button.card-reveal-trigger[type="button"], > :not(aside) button.card-reveal-trigger[type="button"])` |
| `ExpandingCard` | `article.expanding-card:has(> dialog.expanding-card-dialog)` |
| `Carousel` | `.carousel` |
| `Chips` | `.chips` |
| `Confetti` | `.confetti` |
| `Particles` | `canvas.particles` |
| `Keypad` | `.keypad, .keyboard` |
| `ColorPicker` | `.color-picker` |
| `BarChart` | `.bar-chart` |
| `PieChart` | `.pie-chart` |
| `HeatmapChart` | `.heatmap-chart` |
| `RadarChart` | `.radar-chart` |
| `RadialChart` | `.radial-chart` |
| `MixedChart` | `.mixed-chart` |
| `ScatterChart` | `.scatter-chart` |
| `SankeyChart` | `.sankey-chart` |
| `FunnelChart` | `.funnel-chart` |
| `TreemapChart` | `.treemap-chart` |
| `ColumnChart` | `.column-chart` |
| `CommandPalette` | `dialog.command-palette` |
| `Questionnaire` | `form.questionnaire` |
| `Datepicker` | `.date-picker` |
| `LineChart` | `.line-chart` |
| `Menu` | `.menu-trigger` |
| `MessageScroller` | `.message-scroller` |
| `PullToRefresh` | `.pull-to-refresh` |
| `LoadingOverlay` | `.loading-overlay` |
| `SwipeActions` | `.list.swipeable` |
| `InputMask` | `input[data-mask]` of type text, tel, search, url or password |
| `ShowMore` | `.show-more` |
| `CurrencyInput` | `input[data-currency]` of type text |
| `Lightbox` | `.lightboxed` |
| `NavigationRail` | `.navigation-rail` |
| `ScrollSpy` | `.scrollspy` |
| `BackToTop` | `.back-to-top` |
| `FormSelect` | `select` without `data-tree` |
| `TreeSelect` | `select[data-tree]` |
| `Cascader` | `.cascader` |
| `Tabs` | `.tabs` |
| `Timepicker` | `.time-picker` |
| `Tooltip` | `.tooltipped` |
| `HoverCard` | `.hover-card-trigger` |
| `FloatingActionButton` | `.fab-menu` |
| `Menubar` | `.menubar` |

Snackbar, CharacterCounter, and Slider stay out of this table. Slider still starts itself when the bundle loads. Forms, Chips, Cards, ExpandingCard, Dialogs, BottomSheets, and SideSheets also run an import-time `Init()`. Chips, Cards, and ExpandingCard appear in the table as well so a later `AutoInit()` can pick up elements added after load.

### Ignoring elements

If you want `AutoInit()` to skip an element, add the class `no-autoinit`. Initialize that element yourself with the component’s `init` method when you need options other than the defaults.

```html
<a class="button tooltipped no-autoinit" data-tooltip="Hi">Hover</a>
```

```js
Expressive.Tooltip.init(
  document.querySelector('.tooltipped.no-autoinit'),
  { position: 'top' }
);
```

---

## Menu

Material Design 3 menus, from the HTML.

Closing menus are excluded from keyboard focus, and close callbacks preserve focus they move. Submenu entry/return keys mirror in RTL; typeahead stays in the active list and skips headings and disabled items. Escape closes the innermost submenu first. Fine-pointer hover opens flyouts; keyboard activation uses Enter, Space, or the submenu entry arrow.

Inside open or closed shadow roots, `closeOnClick` controls item clicks just as it does in the document. Submenu triggers keep the parent menu open; outside clicks dismiss it.

A `<menu>` is the surface. Each `<li>` is an item. An icon leads its label by default; add `.suffix` to send it to the trailing edge, since a lone icon is indistinguishable from a leading one in CSS. A `<kbd>` or a `.badge` is always trailing content. An `<li class="divider" role="separator">` is a divider — `<menu>` is a list and its content model permits only `<li>`, so a bare `<hr>` between entries is invalid and is not styled; a `.gap` splits groups; a `.label` is a heading. A nested `<menu>` is a flyout. The trigger’s `data-target` must match the menu’s `id`. `.menu-trigger` is the JavaScript contract.

This is the M3 Expressive vertical menu. Tokens follow the [M3 menu spec](https://m3.material.io/components/menus/specs). The container is `surface-container-low`, large (16dp) corners, elevation 2, 2dp group padding, 112–280dp wide. Items are 44dp with extra-small (4dp) corners, `label-large` / `on-surface`; the first and last item round their outer corners to 12dp. Icons are 20dp `on-surface-variant`. Selected items use medium (12dp) corners and `tertiary-container` / `on-tertiary-container`. Hover is an 8% state layer, focus and press 10%, none of which span the container. Dividers are inset. Add `.vibrant` for the tertiary mapping. Submenus fade and scale in; the open flyout rounds up and the parent rounds down.

`AutoInit()` starts every `.menu-trigger` except those marked `no-autoinit`. Menus open on click, below the trigger. Pass `coverTrigger: true` to cover the trigger. Pass `constrainWidth: false` so the menu sizes independently of the trigger. In browsers with CSS anchor positioning, a menu opened from its trigger is anchored there in CSS: it stays under the trigger while the page or a container scrolls, opens above it when the whole menu does not fit below and there is more room above, flips to the other side at the viewport edge, and scrolls when it is taller than the room on the side it opens on. `placement: 'top'` opens it above the trigger when it fits there, as for a trigger in a bottom bar. Up and Down move between rows, and Home and End jump to the first and last. Context menus and `coverTrigger: true` keep the measured placement, as do menus under a transformed ancestor and browsers without anchor positioning. A measured menu follows its trigger when something scrolls. An open menu is a popover in the browser's top layer, so a container that clips or scrolls does not cut it off, a modal dialog does not cover it, and Menu writes no styles on the menu's ancestors.

```html
<button class="menu-trigger" data-target="menu1">Drop me</button>
<menu id="menu1">
  <li><button type="button">One</button></li>
  <li><button type="button">Two</button></li>
  <li class="divider" role="separator"></li>
  <li><button type="button">Three</button></li>
  <li>
    <button type="button">
      <span class="material-symbols" aria-hidden="true">cloud</span>
      <span>Five</span>
    </button>
  </li>
</menu>
```

For a menu inside a top app bar, see Navbar menu.

### Checkbox and radio items

Give an item `role="menuitemcheckbox"` or `role="menuitemradio"` with an `aria-checked` value; a checked item takes the selected style. A checkbox item toggles its own `aria-checked`. A click or Space flips it and keeps the menu open, so several items can be toggled in a row. Enter flips it and then closes the menu, unless `closeOnClick` is false. `aria-checked` changes before `onItemClick` runs; then `expressive:select` fires, then `expressive:change` with `{ item, checked }` from the trigger. A disabled item does not toggle.

A radio item checks itself and unchecks the other radio items in its group. A click or Enter checks it and closes the menu while `closeOnClick` is on; Space checks it and keeps the menu open. The order is the same as for a checkbox item, and `expressive:change` carries `{ item, checked: true }`. Choosing a radio item that is already checked fires `onItemClick` and `expressive:select` but no change. Put each set of radio items in a `<ul role="group">` with an `aria-label`, inside an `<li role="none">`, so a screen reader names the choice. Rows in a group look and move like any other row, and arrow keys and type-ahead pass from one group to the next. Radio items outside a group form one group per menu. A `<ul>` in a row is a group, never a flyout; a flyout is a nested `<menu>`.

```html
<button class="menu-trigger" data-target="view-menu">View</button>
<menu id="view-menu">
  <li role="menuitemcheckbox" aria-checked="true"><button type="button">Ruler</button></li>
  <li role="menuitemcheckbox" aria-checked="false"><button type="button">Grid</button></li>
  <li class="divider" role="separator"></li>
  <li role="none">
    <ul role="group" aria-label="Zoom">
      <li role="menuitemradio" aria-checked="true"><button type="button">Fit</button></li>
      <li role="menuitemradio" aria-checked="false"><button type="button">Fill</button></li>
    </ul>
  </li>
</menu>
```

### Context menu

Add `data-context-menu` to a `.menu-trigger` that is a region of content, such as a file list or a card, and its menu replaces the browser's context menu there. Right-click opens it at the pointer, and it flips to stay on screen. The menu key and Shift+F10 open it at the region's start corner, so give the region `tabindex="0"` when it holds nothing focusable. Enter and Space stay with the region's own content. Closing the menu returns focus to whatever had it before the menu opened. In nested regions only the innermost menu opens, and a right-click on an open menu keeps it open. The menu stays inside the window, and scrolls when it is taller than the window. It stays where it opened when a container around it scrolls; `open()` without a right-click places it at the region's start corner.

The region gets no `aria-expanded` or `aria-haspopup`, which belong to buttons. Every action in a context menu also needs another way in, such as a toolbar or an item's own menu button. Touch browsers that do not fire `contextmenu` on long-press, such as iOS Safari, never show it.

```html
<div class="menu-trigger" data-context-menu data-target="file-actions" tabindex="0">
  …
</div>
<menu id="file-actions">
  <li><button type="button">Rename</button></li>
  <li><button type="button">Duplicate</button></li>
  <li class="divider" role="separator"></li>
  <li><button type="button">Delete</button></li>
</menu>
```

### Initialization

The IIFE bundle exposes `Expressive.Menu`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.menu-trigger`.

```js
document.addEventListener('DOMContentLoaded', function() {
  Expressive.Menu.init(document.querySelectorAll('.menu-trigger'), {
    coverTrigger: false
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  Menu: { constrainWidth: false, coverTrigger: false }
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `alignment` | String | `'left'` | Edge the menu is aligned to. `'left'` or `'right'`. |
| `autoFocus` | Boolean | `true` | If true, automatically focus the menu for keyboard navigation. A menu opened by hover leaves focus where it was until Space, Enter or an arrow key on the trigger. This option is named `autoFocus`, not `autoTrigger`. |
| `constrainWidth` | Boolean | `true` | If true, the menu is as wide as the trigger. M3 menus are independently sized — pass `false` for that. |
| `container` | Element | `null` | Element that will contain the menu. When omitted, the menu is moved next to the trigger. |
| `coverTrigger` | Boolean | `false` | If false, the menu opens below the trigger (the M3 placement). Pass `true` to cover the trigger. |
| `placement` | String | `'bottom'` | The side of the trigger the menu opens on when it fits there: `'bottom'` or `'top'`. It opens on the other side when that has more room. |
| `closeOnClick` | Boolean | `true` | If true, close the menu when an item is clicked. A checkbox item stays open on a click or Space and closes on Enter. A radio item stays open on Space. |
| `hover` | Boolean | `false` | If true, the menu opens on hover instead of click. |
| `contextMenu` | Boolean | `false`; `true` with `data-context-menu` | If true, the trigger is a region and the menu opens at the pointer on right-click, long-press, the menu key or Shift+F10, instead of on click. |
| `inDuration` | Number | `150` | Enter transition duration, in milliseconds. |
| `outDuration` | Number | `250` | Exit transition duration, in milliseconds. |
| `onOpenStart` | Function | `null` | Called when the menu starts opening. |
| `onOpenEnd` | Function | `null` | Called when opening completes; superseded transitions and destruction cancel it. |
| `onCloseStart` | Function | `null` | Called when the menu starts closing. |
| `onCloseEnd` | Function | `null` | Called when closing completes; reopening and destruction cancel it. |
| `onItemClick` | Function | `null` | Called when an item is clicked. Receives the `li`. |

#### Examples

These two menus set `constrainWidth: false` so the list can be wider than the button, and use `alignment` to pick an edge.

```js
Expressive.Menu.init(document.querySelector('#left'), {
  alignment: 'left',
  constrainWidth: false,
  coverTrigger: false
});
```

Hover is off by default. Pass `hover: true` to open on mouse enter instead of click.

```js
Expressive.Menu.init(document.querySelector('#hover'), {
  hover: true,
  constrainWidth: false,
  coverTrigger: false
});
```

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Menu.getInstance(elem);
```

#### .open()

Open the menu.

```text
instance.open();
```

#### .close()

Close the menu. With `autoFocus`, focus returns to the trigger when it remains in the menu. Focus moved to an outside control is preserved.

```text
instance.close();
```

#### .recalculateDimensions()

While the menu is open, recalculate its dimensions if its contents have changed.

```text
instance.recalculateDimensions();
```

#### .destroy()

Destroy the plugin instance and tear down its event handlers.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The trigger the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `id` | String | ID of the menu element. |
| `menuEl` | Element | The menu element. |
| `isOpen` | Boolean | Whether the menu is open. |
| `isScrollable` | Boolean | Whether the menu content is scrollable. |
| `focusedIndex` | Number | Index of the focused item. |

---

## Mega menu

Site navigation whose top items open wide panels of grouped links, on native popovers.

A `nav.mega-menu` holds links and `<button>`s. Each button names a `.mega-menu-panel` with `popovertarget`, so the panel is a native popover: the browser opens it, closes it on Escape or an outside click, puts it next in the Tab order and exposes the button's expanded state. There is no JavaScript and no menu role; these are links read and tabbed in order. The panel opens below the bar, spans the window less `--md-comp-mega-menu-margin`, and lays its `<section>` groups out in as many columns as fit. A `<small>` in a link is a description line. Use a menubar for application commands.

```html
<nav class="mega-menu" aria-label="Main">
  <a href="/" aria-current="page">Home</a>
  <button type="button" popovertarget="products">Products</button>
  <div id="products" class="mega-menu-panel" popover>
    <section>
      <h2>Build</h2>
      <ul>
        <li><a href="/editor">Editor<small>Write and preview</small></a></li>
        <li><a href="/api">API</a></li>
      </ul>
    </section>
  </div>
  <a href="/pricing">Pricing</a>
</nav>
```

Tokens: `--md-comp-mega-menu-container-color` (`surface-container`), `--md-comp-mega-menu-shape` (16px), `--md-comp-mega-menu-margin` (16px) and `--md-comp-mega-menu-column-width` (200px minimum).

---

## Menubar

An application's row of menus, such as File, Edit and View, with the menubar keyboard contract.

A `.menubar` holds `.menu-trigger` buttons, each opening an ordinary Menu written after the bar. The `Menubar` component sets `role="menubar"` on the bar and `role="menuitem"` on each button, and keeps one Tab stop that follows focus. Left and Right move along the bar and wrap, Home and End go to the ends, and both mirror in RTL. Down, Enter and Space open a menu on its first item. In an open menu, Left and Right open the neighbouring menu unless the focused row opens or closes a submenu. Escape closes it and returns to the bar. `AutoInit()` starts it after Menu, and its `menuOptions` (default `{ constrainWidth: false, coverTrigger: false }`) replace each item's Menu options. Name the bar with `aria-label`.

```html
<div class="menubar" aria-label="Editor">
  <button type="button" class="menu-trigger" data-target="file-menu">File</button>
  <button type="button" class="menu-trigger" data-target="edit-menu">Edit</button>
</div>
<menu id="file-menu">
  <li><button type="button">New</button></li>
</menu>
<menu id="edit-menu">
  <li><button type="button">Undo</button></li>
</menu>
```

Tokens: `--md-comp-menubar-container-color` (`surface-container`), `--md-comp-menubar-shape` (12px) and `--md-comp-menubar-item-height` (40px).

---

## Tour

A card that walks people through an interface one step at a time, beside each element it explains.

A `.tour` is a `popover="manual"` element with `role="dialog"`, holding one `<section>` per step. Each section's `data-tour-target` names the id of the element it explains. Add a `.tour-progress` line, a `.tour-actions` row with `[data-tour-back]`, `[data-tour-next]` and `[data-tour-done]` buttons, and a `[data-tour-close]` button; a `[data-tour-start="tour-id"]` button starts it. The `Tour` behavior shows one step at a time below its target (above when there is no room), rings the target with `.tour-target` and scrolls it into view, and skips steps whose target is missing or hidden. Back hides on the first step, Next on the last, and Done shows only on the last. The card is named by the step heading and takes focus. Escape, Done or close end the tour and return focus; the card's `toggle` event reports it. `Expressive.Tour.start(el, index)`, `go(el, index)` and `end(el, completed)` work from script, and the tour fires `expressive:open`, `expressive:change` (`{ step, index }`) and `expressive:close` (`{ completed }`), and `Expressive.Tour.i18n.progress` holds "Step {current} of {total}". The root bundle starts the behavior on load; with the modular entry, call `Tour.Init()`.

```html
<button type="button" data-tour-start="welcome">Take the tour</button>
<div id="welcome" class="tour" popover="manual" role="dialog">
  <section data-tour-target="search-button">
    <h2>Search everything</h2>
    <p>Find files, people and settings from here.</p>
  </section>
  <p class="tour-progress"></p>
  <div class="tour-actions">
    <button type="button" class="button text" data-tour-back>Back</button>
    <button type="button" class="button filled" data-tour-next>Next</button>
    <button type="button" class="button filled" data-tour-done>Done</button>
  </div>
</div>
```

Tokens: `--md-comp-tour-container-color` (`surface-container-high`), `--md-comp-tour-shape` (16px), `--md-comp-tour-max-width` (360px) and `--md-comp-tour-gap` (12px).

---

## Media

Lightbox for enlarge-on-click images.

Media components handle large objects such as images. For responsive images and videos without JavaScript, see Media Styles.

### Lightbox

Lightbox supports Space and native button activation. Repeated open or close requests are ignored; reopening during closing cancels the old transition. Canceled transitions do not report completion. Reduced motion skips animation and finishes pending transitions when enabled while mounted. Destroy cancels pending work, retains the original image, restores owned attributes and styles, and preserves trigger focus without overriding application focus. The open image, its overlay and its caption are manual popovers in the browser's top layer, so a clipping or transformed ancestor does not cut the image off, and Lightbox leaves its ancestors' styles alone. Tab still closes it rather than being trapped: this does not supply a modal-gallery focus contract.

Lightbox is Expressive’s material-style enlarge-on-click image. Click an image with `lightboxed` and it centers and grows. Click it again, scroll, or press Escape to dismiss. `AutoInit()` starts every `.lightboxed` image except those marked `no-autoinit`.

```html
<img class="lightboxed" tabindex="0" role="button" width="650" alt="A mountain lake" src="images/sample-1.jpg">
```

#### Initialization

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.lightboxed');
  const instances = Expressive.Lightbox.init(elems, {
    // specify options here
  });
});
```

#### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `inDuration` | Number | `275` | Open transition duration, in milliseconds. |
| `outDuration` | Number | `200` | Close transition duration, in milliseconds. |
| `onOpenStart` | Function | `null` | Called before the lightbox opens. |
| `onOpenEnd` | Function | `null` | Called after the lightbox opens. |
| `onCloseStart` | Function | `null` | Called before the lightbox closes. |
| `onCloseEnd` | Function | `null` | Called after the lightbox closes. |

#### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Lightbox.getInstance(elem);
```

##### .open();

Open the lightbox.

```text
instance.open();
```

##### .close();

Close the lightbox.

```text
instance.close();
```

##### .destroy();

Destroy the plugin instance and tear down its event handlers.

```text
instance.destroy();
```

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `overlayActive` | Boolean | Whether the lightbox overlay is showing. |
| `doneAnimating` | Boolean | Whether the open or close animation has finished. |
| `caption` | String | Caption text, if specified. |
| `originalWidth` | Number | Original width of the image. |
| `originalHeight` | Number | Original height of the image. |

#### Captions

Add a short caption with the `data-caption` attribute.

```html
<img class="lightboxed" tabindex="0" role="button"
     data-caption="A path through trees in a park"
     width="250"
     alt="A path through trees"
     src="images/sample-2.jpg">
```

---

## Dialogs

Material Design 3 dialogs, from the HTML.

A `<dialog>` is a basic dialog. A heading is the headline, a `<p>` (or a wrapping `<div>`) is supporting text, and the last child `<form method="dialog">` or `<nav>` is the action row. `dialog.max` is the full-screen variant. There are no `.modal`, `modal-header`, `modal-content`, or `modal-footer` classes — the element is the component.

Tokens follow the [M3 dialog spec](https://m3.material.io/components/dialogs/specs). The container is `surface`, 28dp corners, 280–560dp wide, elevation 3. The headline is `headline-small` / `on-surface`; supporting text is `body-medium`. The scrim is `--md-comp-scrim-color`. Actions sit at the end with an 8dp gap. A basic dialog fades in on the fast effects spring and grows from 96% on the default spatial spring (`--md-comp-basic-dialog-effects-easing` and `-effects-duration`, `--md-comp-basic-dialog-easing` and `-duration`), so `motion="expressive"` reaches it. It closes at once and opens without moving when reduced motion is requested. `dialog.max` and sheets keep their own motion.

Open it with `showModal()` and close it with `close()` — the Dialog API, not a plugin. There is no `Modal` export and nothing for `AutoInit()` to start; `Dialogs.Init()` runs at import time and only adds light-dismiss.

```html
<button type="button" onclick="document.getElementById('dialog1').showModal()">
  Show
</button>

<dialog id="dialog1" aria-labelledby="use-location-services-title">
  <h2 id="use-location-services-title">Use location services?</h2>
  <p>Let the app use your location to suggest nearby stops.</p>
  <form method="dialog">
    <button type="submit" class="text" value="disagree">Disagree</button>
    <button type="submit" value="agree">Agree</button>
  </form>
</dialog>
```

A `<form method="dialog">` closes the dialog when a submit button is pressed and sets `dialog.returnValue` from the button’s `value`. An optional leading icon is the M3 dialog icon.

### Methods

Call these on the `<dialog>` element. See [HTMLDialogElement](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement) for the full API.

#### .showModal()

Open as a modal, with the scrim.

```text
dialog.showModal();
```

#### .close()

Close the dialog.

```text
dialog.close();
```

A tap or click on the scrim also closes a basic dialog. Both ends of the gesture have to land outside the dialog's box, so a drag that starts on the dialog, such as selecting text or missing a button, cannot dismiss it. Set `closedby="none"` or `closedby="closerequest"` to opt out.

### Confirm from script

`Expressive.Dialogs.confirm()` asks a yes-or-no question and returns a promise. It builds a basic dialog with `role="alertdialog"`, opens it with `showModal()`, and removes it once it closes. The promise resolves `true` when the person picks the confirm button and `false` on Cancel, Escape or a scrim tap.

```js
const ok = await Expressive.Dialogs.confirm({
  headline: 'Delete draft?',
  text: 'The draft and its attachments are removed.',
  confirm: 'Delete'
});
if (ok) deleteDraft();
```

`text` is the question and is required. `headline` is optional. With a headline, the dialog is named by it and described by the text; without one, the text names it. All four strings are set as text, so markup in them is shown, not parsed. If page code removes the open dialog, for example on a route change, the promise resolves `false`. Focus starts on Cancel, the less destructive action, and the browser returns it to the element that had focus before the call. Label the confirm button with the action it takes, such as "Delete" or "Discard", rather than the "OK" default.

`confirm` and `cancel` set the button labels for one call. `Expressive.Dialogs.i18n` holds the defaults, `{ confirm: 'OK', cancel: 'Cancel' }`; assign to it to change them for every later call. With the modular entry, `confirm()` works without `Dialogs.Init()`, but a scrim tap closes the dialog only after `Init()` runs.

### Tokens

Override these on the `<dialog>` if you need a different surface or width.

| Token | Default |
| --- | --- |
| `--md-comp-basic-dialog-container-color` | `--md-sys-color-surface` |
| `--md-comp-basic-dialog-container-shape` | 28px |
| `--md-comp-basic-dialog-container-min-width` | 280px |
| `--md-comp-basic-dialog-container-max-width` | 560px |

### Bottom sheet

A recent flick dismisses the sheet even if the pointer stops moving at release. For short drags, pausing for at least 100ms or reversing direction lets it snap back.

A `dialog.bottom-sheet` is secondary content anchored to the bottom. Use it on Compact and Medium windows. `showModal()` is the modal variant (scrim). `show()` is the standard variant (no scrim). Same sheet either way: `surface-container-low`, 28dp top corners, 640dp max, 56dp side inset from the Medium breakpoint, 72dp top inset, 32×4 drag handle in a 48dp hit target. Drag the handle down to dismiss. A named `<button>` handle also dismisses on click, tap, <kbd>Enter</kbd>, or <kbd>Space</kbd>. A decorative handle needs another non-drag pointer and keyboard dismiss path, such as a `form method="dialog"` close button. Verify native Escape against the opening mode, browser, and `closedby` policy.

```html
<dialog class="bottom-sheet" aria-labelledby="open-file-title">
  <h2 id="open-file-title">Open file</h2>
  <div>…</div>
  <form method="dialog">
    <button type="submit" class="text" value="cancel">Cancel</button>
  </form>
</dialog>
```

```js
document.getElementById('sheet').showModal(); // modal, with scrim
document.getElementById('sheet').show();      // standard, no scrim
```

`expandable` gives the sheet two heights. It opens partly expanded, at `--md-comp-bottom-sheet-partial-height` (50% of the viewport), and `.expanded` grows it to the full height, 72dp short of the top. A drag up on the handle, or a flick up, expands it; a drag down from expanded collapses it, and a drag down from the partial height dismisses as usual. A handle `<button>` switches between the heights instead of closing, and the component keeps its `aria-expanded` current, so name it for the height, not for dismissal. Closing the sheet puts it back to partial. The height moves on the sheet's spring. Give an expandable sheet an explicit close control, since the handle no longer closes it.

```html
<dialog class="bottom-sheet expandable" aria-labelledby="filters-title">
  <button type="button" class="drag-handle" aria-label="Sheet height" aria-expanded="false"></button>
  <h2 id="filters-title">Filters</h2>
  <div>…</div>
  <form method="dialog"><button type="submit" class="text">Done</button></form>
</dialog>
```

The sheet slides in, and snaps back after a short drag, on the default spatial spring: `--md-comp-bottom-sheet-motion` and `--md-comp-bottom-sheet-motion-duration` read `--md-sys-motion-spring-default-spatial` and its `-duration` token, so `motion="expressive"` gives it a slight bounce. The sheet runs on 24px below the viewport, so the bounce never uncovers the page under it. With reduced motion requested it appears without moving.

### Side sheet

A recent flick toward the docked edge dismisses the sheet. For short drags, pausing for at least 100ms or reversing direction lets it snap back.

A `dialog.side-sheet` is optional content anchored to the side, at the trailing edge unless it has `.start`. `show()` is standard (1dp inner divider, no scrim). `showModal()` is modal (28dp inner corners, scrim). A `<header>` holds an optional back button, a `title-large` headline, and a close control. A last-child `form[method=dialog]` is the action row. Drag the header or the inner 24dp edge toward the docked side to dismiss.

Docking is logical: `.start` uses the start edge, and the default uses the end edge. Drag direction, inner-edge hit testing, modal corners and opening motion follow computed RTL direction. Headings and actions wrap; the body scrolls independently. Shared sheet dragging ignores secondary-pointer cancellation and resets when a sheet closes, is removed, or a primary press starts elsewhere. Dismissal thresholds are unchanged. Keep an explicit close control; native zoom, assistive technology and physical-device review remain pending.

```html
<dialog class="side-sheet" aria-labelledby="headline-title">
  <header>
    <h2 id="headline-title">Headline</h2>
    <form method="dialog">
      <button type="submit" aria-label="Close">
        <span class="material-symbols" aria-hidden="true">close</span>
      </button>
    </form>
  </header>
  <div>…</div>
</dialog>
```

```js
document.getElementById('sheet').show();      // standard
document.getElementById('sheet').showModal(); // modal
```

The sheet slides in, and snaps back after a short drag, on the default spatial spring: `--md-comp-side-sheet-motion` and `--md-comp-side-sheet-motion-duration` read `--md-sys-motion-spring-default-spatial` and its `-duration` token, so `motion="expressive"` gives it a slight bounce. The sheet runs on 24px past its docked edge, so the bounce never uncovers the page there. With reduced motion requested it appears without moving.

### Full-screen

Add `max` for a full-viewport dialog with no corners. That is the M3 full-screen dialog, typically used on small screens.

```html
<dialog class="max" aria-labelledby="new-message-title">
  <h2 id="new-message-title">New message</h2>
  <p>A full-screen dialog fills the viewport.</p>
  <form method="dialog">
    <button type="submit" class="text">Close</button>
    <button type="submit">Save</button>
  </form>
</dialog>
```

---

## Scrollspy

Highlight the table of contents as the page scrolls.

Scrollspy watches a set of sections and which one is currently in view. The table of contents on the right of every documentation page is the live demo: the matching link gets `active`, and clicking a link scrolls to that section.

Put `scrollspy` and an `id` on each section. The table of contents is a set of destinations within the page, so it lives in a labelled `<nav>`; use `table-of-contents` on its list and point each link at `#that-id`. `AutoInit()` starts every `.scrollspy` except those marked `no-autoinit`.

Each link is at least 48dp tall, the Material touch target. For a denser list, set `--md-comp-toc-item-height` on the `.table-of-contents` list.

```html
<div class="row">
  <div class="s12 m9">
    <div id="introduction" class="section scrollspy">
      <p>Content</p>
    </div>
    <div id="structure" class="section scrollspy">
      <p>Content</p>
    </div>
    <div id="initialization" class="section scrollspy">
      <p>Content</p>
    </div>
  </div>
  <div class="hide-on-compact-only m3">
    <nav aria-label="On this page">
      <ul class="section table-of-contents">
        <li><a href="#introduction">Introduction</a></li>
        <li><a href="#structure">Structure</a></li>
        <li><a href="#initialization">Initialization</a></li>
      </ul>
    </nav>
  </div>
</div>
```

### Initialization

The IIFE bundle exposes `Expressive.ScrollSpy`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.scrollspy`.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.scrollspy');
  const instances = Expressive.ScrollSpy.init(elems, {
    // specify options here
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  ScrollSpy: { scrollOffset: 200 }
});
```

The documentation TOC is `position: sticky`. Scrollspy only updates which link is active.

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `scrollOffset` | Number | `200` | Per-section observer top inset and `--md-comp-scrollspy-offset` for native anchor scroll margins. |
| `activeClass` | String | `'active'` | Class applied to the active table-of-contents link. |
| `getActiveElement` | Function | see below | Returns a CSS selector for the element that should receive `activeClass`, given the section’s id. |
| `keepTopElementActive` | Boolean | `false` | If true, keep the last section above the viewport active when the scrollbar is outside all spy sections. If there is no such section, the first one stays active. |

#### getActiveElement

Default function for finding the active link. `id` is the id of the `.scrollspy` section. Return a CSS selector for the element that should be marked active.

Sections sharing the same `getActiveElement` function share one current link.
Use a separate callback for each independent table of contents and reuse that
function for all its sections. Default instances belong to one group. Each
section honors its own `scrollOffset` and `activeClass`. Custom selectors must
escape author-controlled IDs. The default lookup compares literal hrefs.

```text
function(id) {
  return 'a[href="#' + id + '"]';
}
```

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.ScrollSpy.getInstance(elem);
```

#### .destroy();

Disconnect the section's observer, restore its original offset style, and update
the current link within its group. Repeated destruction is safe.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with (the `.scrollspy` section). |
| `options` | Object | The options the instance was initialized with. |

---


## Tabs

Material Design 3 tabs, from the HTML.

A `<nav class="tabs" aria-label="Sections">` of `<a href="#panel">` is the bar. A `<span>` (or the link text) is the label; a leading `<span class="material-symbols">` is the icon. Nest a `<span class="badge">` in the icon for a count, and put that count in the tab's `aria-label` so a hidden icon does not swallow it. `.active` is the selected tab. There is no `li.tab` required — `ul.tabs > li.tab > a` stays as an alias. `AutoInit()` starts every `.tabs` except those marked `no-autoinit`.

Tokens follow the [M3 tabs spec](https://m3.material.io/components/tabs/specs). Primary tabs sit on `surface`, 48dp (64dp with a stacked icon). The label is `title-small` / `on-surface-variant`; selected is `primary`. The indicator is 3dp `primary` with 3dp top corners. The icon is 24dp. A 1dp `outline-variant` divider runs under the bar. Hover is 8%; focus is 10%. Disabled is 38%.

```html
<nav class="tabs" aria-label="Travel">
  <a href="#flight">
    <span class="material-symbols" aria-hidden="true">flight</span>
    <span>Flight</span>
  </a>
  <a class="active" aria-current="page" href="#luggage" aria-label="Luggage, 3 unread">
    <span class="material-symbols" aria-hidden="true">luggage<span class="badge">3</span></span>
    <span>Luggage</span>
  </a>
  <a href="#explore">
    <span class="material-symbols" aria-hidden="true">explore</span>
    <span>Explore</span>
  </a>
</nav>
<div id="flight">Flight</div>
<div id="luggage">Luggage</div>
<div id="explore">Explore</div>
```

Add `disabled` on the `<a>` to make it inaccessible. Tabs become scrollable when there are too many to fit.

Each tab is a link and a Tab stop. When one has focus, Left and Right move focus to the next or previous tab, wrapping at the ends, and Home and End to the first and last; disabled tabs are skipped. In a right-to-left page the arrows still move the way they point. Moving focus does not switch the panel: Enter follows the link. The tabs keep their link semantics and `aria-current`. When the bar scrolls, the selected tab scrolls into view.

Panels that switch in place can make the strip a tablist instead. Write `role="tablist"` and a name on a `<div class="tabs">` (not a `<nav>`), `role="tab"` with `aria-controls` on each link, and `role="tabpanel"` with `aria-labelledby` on each panel. Tabs then keeps `aria-selected` on the selected tab and a roving `tabindex`, so the strip is one Tab stop; Left and Right, Home and End move between the tabs and select as they go. Link tabs stay the default, for tabs that lead to sections or pages.

```html
<div class="tabs" role="tablist" aria-label="Trip details">
  <a role="tab" id="flight-tab" href="#flight" aria-controls="flight">Flight</a>
  <a role="tab" class="active" id="hotel-tab" href="#hotel" aria-controls="hotel">Hotel</a>
</div>
<div id="flight" role="tabpanel" aria-labelledby="flight-tab">Flight</div>
<div id="hotel" role="tabpanel" aria-labelledby="hotel-tab">Hotel</div>
```

### Secondary

`tabs-secondary` is the in-content variant: always 48dp, icons inline, a 2dp indicator. Use it to further split a pane.

```html
<nav class="tabs tabs-secondary" aria-label="Trip">
  <a href="#travel">
    <span class="material-symbols" aria-hidden="true">flight</span>
    <span>Travel</span>
  </a>
  <a class="active" aria-current="page" href="#hotel">
    <span class="material-symbols" aria-hidden="true">hotel</span>
    <span>Hotel</span>
  </a>
</nav>
```

### Initialization

The IIFE bundle exposes `Expressive.Tabs`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.tabs`.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.tabs');
  const instances = Expressive.Tabs.init(elems, {
    swipeable: false
  });
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `duration` | Number | `null` | Indicator transition duration in milliseconds, on an ease-out curve. The default, `null`, moves the indicator on the default spatial spring (`--md-comp-tabs-indicator-easing` and `--md-comp-tabs-indicator-duration`), which follows `motion="expressive"` and stops under reduced motion. |
| `onShow` | Function | `null` | Called when a new tab panel is shown. Receives the panel element. |
| `swipeable` | Boolean | `false` | Enable swipeable tabs. Uses `responsiveThreshold`. Moves the panels into a scroll-snap track. |
| `responsiveThreshold` | Number | `Infinity` | Maximum viewport width, in pixels, at which swipeable mode starts. Wider viewports stay non-swipeable. |

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Tabs.getInstance(elem);
```

#### .select();

Show the panel that belongs to the tab with this id. Panel IDs are matched literally, including numeric IDs and IDs containing dots, colons or brackets, in normal and swipeable modes.

**String:** The id of the tab panel (without `#`).

```text
instance.select('tab_id');
```

#### .updateTabIndicator();

Recalculate the indicator position. Useful if the bar was hidden or resized.

```text
instance.updateTabIndicator();
```

#### .destroy();

Destroy the plugin instance and tear down its event handlers.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with (the `.tabs` bar). |
| `options` | Object | The options the instance was initialized with. |
| `index` | Number | Index of the tab that is currently shown. |

### Preselecting a tab

The first tab is selected by default. To pick another, add `active` to its `<a>`, or load the page with a matching hash such as `#test2`.

```html
<a class="active" href="#test3">Test 3</a>
```

### Linking to an External Page

Tabs ignore default anchor behaviour. To keep a tab as a normal hyperlink, set `target` on the link.

```html
<nav class="tabs" aria-label="Sections">
  <a target="_blank" href="https://github.com">External link in new window</a>
  <a target="_self" href="https://github.com">External link in same window</a>
</nav>
```

### Swipeable tabs

Set `swipeable: true` to swipe between panels with touch or a trackpad. Keep the tab panels in the same wrapping container. Tabs moves those panels into a full-width `.tabs-content` scroll-snap track, and only the shown panel is reachable by keyboard. `responsiveThreshold` is the maximum viewport width at which swipeable mode initializes; resizing later does not switch modes. Each changed swipeable selection updates `aria-current` and calls `onShow` once with the shown panel. `destroy()` removes the track and restores panel positions and Tabs-owned state.

This demo is marked `no-autoinit` and started with `swipeable: true`.

```html
<nav id="tabs-swipe-demo" class="tabs" aria-label="Sections">
  <a href="#test-swipe-1">Test 1</a>
  <a class="active" aria-current="page" href="#test-swipe-2">Test 2</a>
  <a href="#test-swipe-3">Test 3</a>
</nav>
```

```js
Expressive.Tabs.init(document.querySelector('#tabs-swipe-demo'), {
  swipeable: true
});
```

### Fixed width

Add `max` (or `tabs-fixed-width`) so every tab grows equally. On compact viewports every tab bar already uses this layout.

```html
<nav class="tabs max" aria-label="Sections">
  <a href="#test1">Test 1</a>
  <a class="active" aria-current="page" href="#test2">Test 2</a>
  <a class="disabled" href="#test3">Disabled</a>
</nav>
```

### Inline icons

Primary tabs stack the icon above the label (64dp). Add `horizontal` to put them on one line, like secondary tabs.

```html
<nav class="tabs max horizontal" aria-label="Sections">
  <a href="#flight">
    <span class="material-symbols" aria-hidden="true">flight</span>
    <span>Flight</span>
  </a>
  <a class="active" aria-current="page" href="#luggage">
    <span class="material-symbols" aria-hidden="true">luggage</span>
    <span>Luggage</span>
  </a>
</nav>
```

---

## Snackbar

Snackbars with an action are persistent and dismissible by default. Escape dismisses a focused snackbar; closing restores focus only when it remains inside the bar. Explicit finite timers still pause during focus or hover.

Material Design 3 snackbars, from the HTML.

Snackbars show short updates about app processes at the bottom of the screen. They should not interrupt browsing. A `.snackbar` is the bar. A `<p>` is the supporting text. A trailing `<button>` is the optional action; a `.circle` button is the optional close.

Tokens follow the [M3 snackbar spec](https://m3.material.io/components/snackbar/specs). The container is `inverse-surface`, 4dp corners, elevation 3, 48dp minimum. Supporting text is `body-medium` / `inverse-on-surface`, two lines max. The action is a `label-large` / `inverse-primary` text button. Close is a 24dp `inverse-on-surface` icon. On Compact viewports the bar is inset 8dp from the edges; from the Medium breakpoint up it hugs content (344–672dp) and sits centered 24dp from the bottom.

A text-only snackbar defaults to 4 seconds. Setting `action` defaults to `displayLength: Infinity` and `dismissible: true`; explicit options override these defaults. Only one shows at a time. The live region is `role="status"` / `aria-live="polite"` and does not steal focus. Snackbar is not in `AutoInit()`. Use ordinary Tab navigation for its actions and Dismiss. For quicker access, provide a visible notification-actions button near the task trigger whose click handler calls `instance.el.querySelector('button, a[href]')?.focus()`. Never autofocus on creation. Escape within a snackbar dismisses it without closing an enclosing dialog. Closing restores the prior connected focus target only if focus remains inside; application-directed focus is preserved. For custom template controls, explicitly set `displayLength: Infinity` and `dismissible: true`.

```js
new Expressive.Snackbar({ text: 'Photo saved to album' });

new Expressive.Snackbar({
  text: 'Item archived',
  action: 'Undo',
  onAction: function() { /* restore */ }
});

new Expressive.Snackbar({
  text: "Can't send photo. Retry in 5 seconds.",
  action: 'Retry',
  dismissible: true
});
```

The constructor wraps `text` in a `<p>` and appends the action and close when those options are set.

### Initialization

The IIFE bundle exposes `Expressive.Snackbar`. Snackbar is not in `AutoInit()` — construct one when you need it.

```js
new Expressive.Snackbar({
  text: 'I am a snackbar!'
});
```

One way to hook that up is a click handler on a button:

```html
<button type="button" id="snackbar-basic">Show</button>
```

```js
document.getElementById('snackbar-basic').addEventListener('click', function() {
  new Expressive.Snackbar({ text: 'I am a snackbar!' });
});
```

### Markup

The same anatomy works as static HTML. Without `.active` the bar is in-flow — useful for previews. With `.active` it pins to the bottom of the viewport, centered from the Medium breakpoint.

```html
<div class="snackbar">
  <p>Photo saved to album</p>
</div>

<div class="snackbar">
  <p>Item archived</p>
  <button type="button">Undo</button>
</div>

<div class="snackbar active">
  <p>Single-line snackbar with action</p>
  <button type="button">Action</button>
</div>
```

`.active` does not dismiss itself. Add and remove the class, or use the constructor if you want the 4-second timer and swipe-to-dismiss.

### Options

You can customize each snackbar with these options.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | String | `''` | Plain-text supporting text, wrapped in a `<p>`. If set, it replaces any HTML from `snackbarId`. |
| `action` | String | `''` | Optional action label. Rendered as a trailing text button. |
| `onAction` | Function | `null` | Called when the action button is pressed. The snackbar still dismisses. |
| `dismissible` | Boolean | `false`, or `true` with an action | Show a trailing close icon button. An explicit false is preserved. |
| `snackbarId` | String | — | Id of a `<template>` (or another element) used as the snackbar body. Templates are cloned. Other elements are moved into the snackbar and restored on dismissal or replacement, so the same ID can be reused. |
| `displayLength` | Number | `4000` | Default 4000ms without an action, or Infinity with an action. Explicit finite timers need equivalent persistent feedback in the application. |
| `inDuration` | Number | `300` | Enter transition duration, in milliseconds. |
| `outDuration` | Number | `375` | Exit transition duration, in milliseconds. |
| `classes` | String | `''` | Space-separated classes added to the snackbar. `rounded` is a stadium. `top` moves the bar off the bottom. |
| `completeCallback` | Function | `null` | Called when the snackbar is dismissed. |
| `activationPercent` | Number | `0.8` | Fraction of the snackbar’s width a drag must travel to dismiss it. |
| `root` | Element | `null` | Any element in the tree the snackbar should render into. Pass one when the page lives in a shadow root, so the container is appended there rather than to `document.body`. |
| `replace` | Boolean | `false` | Show at once, cutting the snackbar on screen short, instead of waiting behind it. Copy buttons use this so a second copy answers right away. |

One snackbar shows at a time. A new one waits until the one on screen is dismissed, then shows, so updates appear in order. A queued snackbar captures focus and fires `expressive:open` when it appears; one dismissed while still waiting runs `completeCallback` and never shows. A snackbar built from `snackbarId` takes its element when it shows, so its `el` is unset while it waits.

### Methods

> Instance methods are called on the snackbar. You can get the instance like this:

```js
const instance = Expressive.Snackbar.getInstance(elem);
```

#### .dismiss();

Dismiss this snackbar with its exit animation. Runs `completeCallback` when the animation finishes.

```text
instance.dismiss();
```

#### Snackbar.dismissAll();

Dismiss the snackbar on screen and every one waiting behind it.

```js
Expressive.Snackbar.dismissAll();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The snackbar element. |
| `options` | Object | The options the instance was initialized with. |
| `panning` | Boolean | Whether the snackbar is being dragged. |
| `timeRemaining` | Number | Milliseconds left before the snackbar dismisses. |

### Custom HTML

Pass `snackbarId` pointing at a `<template>`. The first child of the template is cloned as the snackbar. Leave `text` empty so the HTML is kept. Use the same anatomy as a static snackbar.

```html
<button type="button" class="tonal" id="snackbar-html-1">Show Snackbar 1</button>
<template id="my-snackbar-1">
  <div>
    <p>This is snackbar nº1 with a <a href="https://github.com">link</a></p>
  </div>
</template>
```

```js
new Expressive.Snackbar({ snackbarId: 'my-snackbar-1', displayLength: Infinity, dismissible: true });
```

### Callback

Run a function when the snackbar is dismissed.

```js
new Expressive.Snackbar({
  text: 'I will call back when dismissed',
  completeCallback: function() {
    new Expressive.Snackbar({ text: 'Your snackbar was dismissed' });
  }
});
```

### Styling

Pass classes in the `classes` option. `rounded` is a 24dp stadium — the M3 default is 4dp. Snackbars sit at the bottom; `top` is the exception.

```js
new Expressive.Snackbar({
  text: 'I am a snackbar!',
  classes: 'rounded'
});

new Expressive.Snackbar({
  text: 'Posted from the top',
  classes: 'top'
});
```

### Dismiss a Snackbar Programmatically

To remove a specific snackbar, get the instance from the snackbar element and call `dismiss()`. Swipe also dismisses when you drag past 80% of the width or flick. After a short drag, pausing for at least 100ms before release lets the snackbar snap back. Cancelling a swipe leaves the snackbar open and does not affect the next gesture. The action and close buttons are not swipe handles.

```js
const snackbarElement = document.querySelector('#snackbar-container .snackbar');
Expressive.Snackbar.getInstance(snackbarElement)?.dismiss();
```

#### Dismiss all snackbars

```js
Expressive.Snackbar.dismissAll();
```

---

## Tooltips

Material Design 3 tooltips, from the HTML.

A child `.tooltip` is the bubble. No `tooltipped` class is required. They are CSS only on hover and keyboard focus. Helpers are only for placement and the rich variant: `top` (the default), `bottom`, `left`, `right`, `rich`.

Tokens follow the [M3 tooltip spec](https://m3.material.io/components/tooltips/specs). A plain tooltip is `inverse-surface` / `inverse-on-surface`, 4dp corners, `body-small`, 24dp minimum, 4/8dp padding, 200dp max, no elevation and no caret. It sits 4dp from the activator. M3 prefers above.

Inside a `<button>` the bubble has to be a `<span>` — a `<div>` is not phrasing content and the parser will hoist it. Icon-only buttons still need `.circle` so the span is not treated as a label.

```html
<button type="button" class="circle" aria-label="Add" aria-describedby="tip-add-to-album">
  <span class="material-symbols" aria-hidden="true">add</span>
  <span class="tooltip" id="tip-add-to-album">Add to album</span>
</button>

<button type="button" class="circle" aria-label="Below" aria-describedby="tip-below">
  <span class="material-symbols" aria-hidden="true">arrow_downward</span>
  <span class="tooltip bottom" id="tip-below">Below</span>
</button>
```

### Rich

`rich` is the rich tooltip: `surface`, elevation 2, 12dp corners, 320dp max. A heading is the title (`title-small`), a `<p>` is supporting text (`body-medium`), and a trailing `<nav>` is the action. Rich bubbles can receive pointer events so the action is usable.

```html
<div>
  <button type="button" class="tonal">Why this is saved</button>
  <div class="tooltip rich bottom">
    <h3>Saved offline</h3>
    <p>This stop is stored on the device so it still opens without a signal.</p>
    <div class="actions">
      <button type="button" class="text">Got it</button>
    </div>
  </div>
</div>
```

A rich tooltip with an action cannot live inside a `<button>` — that would nest buttons. Put the bubble next to the control, wrapped in a parent.

### JavaScript

The CSS path does not need AutoInit. The JS plugin is still there for `data-tooltip`, delayed show/hide, and keeping the bubble inside the viewport. Add `tooltipped` to the activator. `data-tooltip` is the text; `data-position` is `top`, `right`, `bottom`, or `left`. `AutoInit()` starts every `.tooltipped` except those marked `no-autoinit`. The generated element gets `.tooltip` (plus `.rich` when `tooltipId` is set). In browsers with CSS anchor positioning, the plugin anchors the bubble to its activator instead of measuring: it stays beside the activator while the page or a container scrolls, and flips to the opposite side at the viewport edge. An anchored tooltip fades in without sliding, because script cannot tell which side it flipped to; `transitionMovement` is added to the gap instead. Other browsers keep the measured, viewport-clamped position.

```html
<button type="button" class="tooltipped" data-position="bottom" data-tooltip="I am a tooltip">
  Hover me
</button>
```

For HTML, point `data-tooltip-id` at an element. That element is moved into the tooltip and the bubble is marked `rich`. Leave `data-tooltip` off so the HTML is kept. There is no `data-html` attribute and no `unsafeHTML` option.

```html
<button type="button" class="tooltipped"
        data-position="bottom" data-tooltip-id="tooltip-content">
  With HTML
</button>
<div id="tooltip-content" hidden>
  <h3>Chart</h3>
  <p>This is a tooltip with a <a href="https://github.com">link</a>.</p>
</div>
```

### Initialization

The IIFE bundle exposes `Expressive.Tooltip`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.tooltipped`. On touch, a runtime tooltip shows on a long press of half a second, without `enterDelay`, and stays for a second and a half after the finger lifts; the long press does not press the button or follow the link, and a tap, or a press that turns into a scroll, shows nothing. A tooltip that opens while a tooltip is closing, or within 300 ms after that one's `exitDelay` ends, shows without waiting its `enterDelay`, so moving along a toolbar shows each tooltip at once. With `openOnClick`, a tap is the click: it opens and closes the tooltip, and the long press is off.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.tooltipped');
  const instances = Expressive.Tooltip.init(elems, {
    enterDelay: 200
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  Tooltip: { enterDelay: 200 }
});
```

### Options

Constructor and `init` options. `data-tooltip`, `data-position`, and `data-tooltip-id` are read from the element and override these defaults. Attributes are read again each time the tooltip opens, so changing them later takes effect on the next show.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `exitDelay` | Number | `200` | Delay before the tooltip disappears, in milliseconds. |
| `enterDelay` | Number | `0` | Delay before the tooltip appears, in milliseconds. Skipped while a tooltip is closing and for 300 ms after its `exitDelay` ends. |
| `tooltipId` | String | — | Id of an element used as the tooltip body. Set by `data-tooltip-id`. Marks the bubble `rich`. |
| `text` | String | `''` | Plain-text content. Set by `data-tooltip`. Ignored when `tooltipId` is set. |
| `margin` | Number | `4` | Distance from the activator, in pixels, not counting `transitionMovement`. M3 is 4dp. |
| `inDuration` | Number | `250` | Enter transition duration, in milliseconds. |
| `opacity` | Number | `1` | Opacity of the tooltip when shown. |
| `outDuration` | Number | `200` | Exit transition duration, in milliseconds. |
| `position` | String | `'bottom'` | Direction: `'top'`, `'right'`, `'bottom'`, or `'left'`. Set by `data-position`. The CSS-only default is above. |
| `transitionMovement` | Number | `10` | How far the tooltip moves during its transition, in pixels. An anchored tooltip does not move and adds this to the gap. |
| `openOnClick` | Boolean | `false` | Open on a click instead of hover and focus, at once, without `enterDelay`. A second click, Escape, a click elsewhere or focus moving to another element closes it at once; a click inside the tooltip does not. Keyboard users open it with Enter or Space, so the trigger should be a `<button>`. |

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Tooltip.getInstance(elem);
```

#### .open();

`open()` and `open(true)` show the tooltip after its enter delay without requiring hover or focus. `open(false)` uses the automatic behavior and requires hover or focus when the delay ends. With `openOnClick`, a click elsewhere also closes a tooltip opened this way.

```text
instance.open();
```

#### .close();

Hide the tooltip.

```text
instance.close();
```

#### .destroy();

Destroy the plugin instance and remove its generated tooltip and event handlers. Rich content returns to its authored position and presentation state. Existing description associations are preserved, so the same content can be initialized again.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The DOM element the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `isOpen` | Boolean | Whether the tooltip is open. |
| `isHovered` | Boolean | Whether the activator is hovered. |
| `isFocused` | Boolean | Whether the activator is focused via the keyboard. |
| `tooltipEl` | Element | The generated tooltip element. |

---

## Toolbars

Material Design 3 toolbars, from the HTML.

A `<div class="toolbar">` is the bar — not a `<nav>`, because a toolbar holds commands rather than destinations. Direct `<button>` or `<a>` children are the actions. A `<span class="material-symbols">` is the icon; wrap a label in its own `<span>`. An action with an icon and no label needs an `aria-label`. `.active` marks the selected action. They are CSS only. There is no JavaScript component and nothing to AutoInit.

Tokens follow the [M3 toolbar spec](https://m3.material.io/components/toolbars/specs). Actions are 48dp targets with a 24dp icon, transparent at rest, `on-surface-variant`, 4dp apart.

The FAB-to-toolbar transition has been removed. Use this toolbar for persistent commands. Do not put `toolbar` on every `<nav>`; app bars, card actions, and radio rows remain separate.

```html
<div class="toolbar">
  <button type="button" class="circle" aria-label="Undo">
    <span class="material-symbols" aria-hidden="true">undo</span>
  </button>
  <button type="button" class="circle active" aria-label="Bold">
    <span class="material-symbols" aria-hidden="true">format_bold</span>
  </button>
  <button type="button" class="circle" aria-label="Italic">
    <span class="material-symbols" aria-hidden="true">format_italic</span>
  </button>
</div>
```

A label next to the icon needs a `<span>` — `:only-child` ignores text nodes, so `<span class="material-symbols">edit</span>Edit` would look icon-only.

```html
<div class="toolbar">
  <button type="button">
    <span class="material-symbols" aria-hidden="true">edit</span>
    <span>Edit</span>
  </button>
  <button type="button" class="circle" aria-label="More">
    <span class="material-symbols" aria-hidden="true">more_vert</span>
  </button>
</div>
```

### Variants

Four, from two independent axes — a **shape** (floating or docked) and a **color style** (standard or vibrant).

| | standard | vibrant |
| --- | --- | --- |
| **floating** | `class="toolbar"` | `class="toolbar vibrant"` |
| **docked** | `class="toolbar docked"` | `class="toolbar docked vibrant"` |

Floating is the default shape and standard the default color, so neither needs a class — but `floating` and `standard` are both accepted, so a bar can name all of what it is.

**Floating** hugs its actions: 64dp tall, 32dp stadium corners, elevation 3, 8dp end insets, 16dp from the viewport edge (24dp when `vertical`). **Docked** is full width, square, unlifted, 16dp end insets, its actions between 4dp and 32dp apart. `vertical` stacks a floating bar; a docked bar stays a horizontal strip.

**Standard** is `surface-container` with `on-surface-variant` content and a `secondary-container` selection. **Vibrant** is `primary-container` with `on-primary-container` content, and selection moves to `surface-container` / `on-surface` so it still reads against the accent.

The `vibrant` *attribute* is a different thing, and the bar deliberately does not read it as a class. That attribute is the [emphasis foundation](#vibrant-emphasis), whose one ramp is tertiary; a bar inside it — or carrying it — is repainted by the foundation alone, with no toolbar-specific code, because every colour above is a token pointed at a `--md-sys-color-*` role. Nothing collides: selection is `secondary-container`, which the foundation leaves alone.

```html
<div class="toolbar vibrant">…</div>
<div class="toolbar docked vibrant">…</div>
<div class="toolbar floating standard vertical">…</div>
```

### Docked

`docked` stretches the bar to the full width, drops the stadium and the elevation, and spaces the actions. Use it for page actions at the bottom of the screen; destinations belong on a navigation bar. A child `.max` is a spacer, not the bar.

```html
<div class="toolbar docked">
  <button type="button" class="circle" aria-label="Back">
    <span class="material-symbols" aria-hidden="true">arrow_back</span>
  </button>
  <button type="button" class="circle" aria-label="Add">
    <span class="material-symbols" aria-hidden="true">add</span>
  </button>
  <span class="max"></span>
  <button type="button" class="filled" aria-label="Create">
    <span class="material-symbols" aria-hidden="true">edit</span>
  </button>
</div>
```

### Pairing with a FAB

A floating bar can sit beside a companion FAB. Wrap both in `.toolbar-group`; the FAB is a sibling of the bar, not a child, and stays a FAB — 56dp, 16dp corners — 8dp away and lifted one level where the bar is lifted three. Its colors follow the bar's color style: `secondary-container` beside a standard bar, `tertiary-container` beside a vibrant one. Mark only the bar. That is the companion FAB, not the in-bar `.filled` action.

```html
<div class="toolbar-group">
  <div class="toolbar vibrant">…</div>
  <button type="button" class="extra circle" aria-label="Reply">
    <span class="material-symbols" aria-hidden="true">reply</span>
  </button>
</div>
```

### Fixed

`fixed` pins the bar to the viewport, inset by its own external space. A floating bar sits 16dp from the bottom-center; add `top` to move it to the top. A vertical bar sits 24dp from the start edge; `right` flips it. A docked bar sticks to the bottom edge and states its own inset; `top` sticks it to the top.

```html
<div class="toolbar fixed">…</div>
<div class="toolbar vertical fixed">…</div>
<div class="toolbar docked fixed">…</div>
```

---

# Forms

## Command palette

A searchable list of commands in a dialog, opened from anywhere with a shortcut.

A `<dialog class="command-palette">` holds a search `<input>`, a `<ul>` of commands and an optional `.command-palette-empty` message. Each command is an `<li>` holding a link or button: an optional leading icon, the label and an optional trailing `<kbd>` shortcut. An `<li class="label">` heads a group. Running a command clicks its link or button, so commands are ordinary links and click handlers. Commands added to the list later are picked up. A command the page hides with `hidden` stays hidden, and one whose button is `disabled` is shown dimmed, marked `aria-disabled` and skipped by the arrow keys. `AutoInit()` starts every `dialog.command-palette`.

Ctrl+K, or Command+K on Apple platforms, opens and closes it from anywhere on the page unless the page has already handled the key, as an editor's own Ctrl+K does; a `commandfor` button with `command="show-modal"` opens it too. Typing filters the commands by their text and any `data-keywords`, ignoring case and accents, and hides a group heading whose commands are all filtered out. Focus stays in the input: the arrow keys move the active command and Enter runs it. The dialog closes on Escape and returns focus. Each opening starts with an empty search.

The component makes the input a combobox and the list a listbox of options, and reports the active option with `aria-activedescendant`. Name the dialog and the input with `aria-label`. Give the empty message `role="status"` so a search with no results is announced.

```html
<button type="button" commandfor="commands" command="show-modal">
  Commands <kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd>
</button>
<dialog id="commands" class="command-palette" aria-label="Commands">
  <input type="search" placeholder="Type a command" aria-label="Search commands">
  <ul>
    <li class="label">Files</li>
    <li>
      <button type="button" data-keywords="create add">
        <span class="material-symbols" aria-hidden="true">note_add</span>
        New file
        <kbd>Ctrl N</kbd>
      </button>
    </li>
    <li><a href="/settings">
      <span class="material-symbols" aria-hidden="true">settings</span>
      Settings
    </a></li>
  </ul>
  <p class="command-palette-empty" role="status">No matching commands</p>
</dialog>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `shortcut` | String | `'k'` | Letter that toggles the palette with Command on Apple platforms or Ctrl elsewhere. A key event the page already handled is left alone. `null` turns it off. `data-shortcut` on the dialog sets it, and an empty `data-shortcut` turns it off. |
| `closeOnRun` | Boolean | `true` | If true, the palette closes after a command runs. |

### Methods

#### .open()

Opens the palette as a modal dialog with the search input focused.

#### .close()

Closes the palette.

#### .destroy()

Removes the shortcut, listeners, roles and ids the component added.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-command-palette-max-width` | 640px |
| `--md-comp-command-palette-item-height` | 48px |
| `--md-comp-command-palette-active-container-color` | `secondary-container` |

---

## Date Picker

Select a date, a range, or several dates from a calendar.

Add `date-picker` to a text input. `AutoInit()` starts every `.date-picker` except those marked `no-autoinit`.

The calendar is inline, not a modal. `open()` and `close()` are not provided. With the default options the calendar is hidden (`openByDefault: false`) and clicking the input does not reveal it. Pass `openByDefault: true` to show the calendar under the field.

```html
<div class="field">
  <input type="text" class="date-picker" id="birthdate">
  <label for="birthdate">Birthdate</label>
</div>
```

```js
Expressive.Datepicker.init(document.querySelectorAll('.date-picker'), {
  openByDefault: true
});
```

Wrap the input in its own small parent — a `.field`, or a bare `<div>`. The calendar is inserted after that parent, not after the input itself.

### Initialization

The IIFE bundle exposes `Expressive.Datepicker`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.date-picker`. AutoInit uses the defaults, so the calendar stays hidden until you pass `openByDefault` (or a working `displayPlugin` pair, below).

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.date-picker');
  const instances = Expressive.Datepicker.init(elems, {
    openByDefault: true
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  Datepicker: { openByDefault: true }
});
```

### Display

The calendar is a `.datepicker-container` in the page, unless `displayPlugin: 'modal'` puts it in a dialog. There is no `autoClose` option.

#### Always visible

`openByDefault: true` leaves the calendar in the layout. That is the reliable way to show it.

#### Docked popover

`displayPlugin: 'docked'` wraps the calendar in a `.display-docked` popover that appears when the input is clicked or focused with Enter, and hides when you click outside. The plugin only animates the wrapper. If `openByDefault` is still `false`, the calendar inside stays `display: none`. Use both:

```js
Expressive.Datepicker.init(elem, {
  displayPlugin: 'docked',
  openByDefault: true
});
```

The popover stays inside the input’s parent and accounts for its positioning and scroll offsets. Positioned wrappers such as `.field` can be used directly.

Optional `displayPluginOptions`: `margin` (default `5`), `transition` (`10`), `duration` (`250`), and `align` (`'left'`).

#### Modal dialog

`displayPlugin: 'modal'` opens the calendar in a modal `<dialog class="display-modal">`, drawn as a basic dialog over a scrim, when the input is clicked or focused with Enter. The dialog takes the input's label as its name and focus moves to the selected day. Picking a day writes nothing until Ok; Cancel, Escape or a press on the scrim close it without changing the input and call `onCancel`. Focus then returns to the input. A modal calendar always shows Cancel and Ok, so it sets `openByDefault: true` and `autoSubmit: false` for you.

```js
Expressive.Datepicker.init(elem, {
  displayPlugin: 'modal'
});
```

`container` renders the calendar into a specific element and does not hide it. Use a selector or a DOM node.

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `format` | String or Function | `'mmm dd, yyyy'` | Output written to the input, or a function that takes a `Date` and returns a string. |
| `parse` | Function | `null` | Turn the current input string back into a `Date`. Receives `(value, format)`, where `format` is the configured string or an empty string for a function formatter. Blank inputs skip parsing. |
| `isDateRange` | Boolean | `false` | Select a start date and an end date. |
| `dateRangeEndEl` | String | `null` | Selector for an existing end-date input. If omitted, a second input is created. |
| `isMultipleSelection` | Boolean | `false` | Toggle several dates. Extra inputs are created as dates are added. |
| `defaultDate` | Date | `null` | Initial date to view. Falls back to the input’s current value, then today. |
| `defaultEndDate` | Date | `null` | Initial end date when `isDateRange` is true. |
| `setDefaultDate` | Boolean | `false` | If true, `defaultDate` is also the selected value. |
| `setDefaultEndDate` | Boolean | `false` | If true, `defaultEndDate` is also the selected end value. |
| `disableWeekends` | Boolean | `false` | Prevent selecting Saturday and Sunday. |
| `disableDayFn` | Function | `null` | Return true to disable that day. Receives a `Date`. |
| `firstDay` | Number | `0`, or the language's | First day of the week. `0` is Sunday, `1` is Monday. With a `lang` on or around the input, defaults to that language's first day where the browser knows it. |
| `minDate` | Date | `null` | Earliest selectable date. |
| `maxDate` | Date | `null` | Latest selectable date. |
| `yearRange` | Number or Array | `10` | Years on either side of the viewed year, or `[minYear, maxYear]`. |
| `yearRangeReverse` | Boolean | `false` | Sort the year list in reverse order. |
| `isRTL` | Boolean | `false` | Render the calendar right-to-left. |
| `showMonthAfterYear` | Boolean | `false` | Show the month after the year in the title. |
| `showDaysInNextAndPreviousMonths` | Boolean | `false` | Render days that fall in the adjoining months. |
| `openByDefault` | Boolean | `false` | If true, the calendar is visible. If false, it is given `display: none`. |
| `container` | Element or String | `null` | Element or selector to render the calendar into. When set, the calendar is not hidden. |
| `showClearBtn` | Boolean | `false` | Show a Clear button in the footer. |
| `autoSubmit` | Boolean | `true` | If true, selecting a day writes the input immediately. If false, Ok and Cancel buttons are added. |
| `i18n` | Object | See below | Labels and month/weekday names. Partial objects are merged with the defaults. With a `lang` on or around the input, the month and weekday names default to that language's, as `Intl` writes them; the button words stay English unless passed. |
| `events` | Array | `[]` | Strings from `Date.toDateString()`. Matching days get a `has-event` class. The default stylesheet does not style that class. |
| `onSelect` | Function | `null` | Called when a date is selected. Receives the `Date`. |
| `onDraw` | Function | `null` | Called after the calendar HTML is redrawn. |
| `onInputInteraction` | Function | `null` | Called when the input is clicked or confirmed with Enter. |
| `onConfirm` | Function | `null` | Called once when Ok is activated by click, Enter or Space. The button is created when `autoSubmit` is false. |
| `onCancel` | Function | `null` | Called once when Cancel is activated by click, Enter or Space. The button is created when `autoSubmit` is false. |
| `displayPlugin` | String | `null` | Set to `'docked'` for a click-to-open popover, paired with `openByDefault: true`, or `'modal'` for a dialog with Cancel and Ok. |
| `displayPluginOptions` | Object | `null` | Options for the docked plugin: `margin`, `transition`, `duration`, `align`. The modal plugin takes none. |

### Date format options

Without a custom `parse` callback, `YYYY-MM-DD` input is a local calendar date.
Impossible dates such as `2023-02-29` do not change the selection. Text in the
picker's own string `format` is read with its `i18n` names, so `05/10/2026` in
`dd/mm/yyyy` is 5 October and a German `Mär 15, 2026` reads back. Other strings
use the browser's date parser. Supply `parse` for other input formats.

The custom parser also applies when initializing or reopening the picker by click
or Enter. Clearing a single-date input clears its selection, so confirming does
not restore the deleted date.

Use these tokens in the `format` string.

| Key | Description | Output |
| --- | --- | --- |
| `d` | Date of the month. | 1–31 |
| `dd` | Date of the month, two digits. | 01–31 |
| `ddd` | Weekday short name from `i18n`. | Sun–Sat |
| `dddd` | Weekday full name from `i18n`. | Sunday–Saturday |
| `m` | Month of the year. | 1–12 |
| `mm` | Month of the year, two digits. | 01–12 |
| `mmm` | Month short name from `i18n`. | Jan–Dec |
| `mmmm` | Month full name from `i18n`. | January–December |
| `yy` | Two-digit year. | 26 |
| `yyyy` | Four-digit year. | 2026 |

### Internationalization

Pass a partial `i18n` object. Missing keys keep the English defaults.

| Key | Default |
| --- | --- |
| `cancel` | `'Cancel'` |
| `clear` | `'Clear'` |
| `done` | `'Ok'` |
| `previousMonth` | `'Previous month'` |
| `nextMonth` | `'Next month'` |
| `months` | `['January', …, 'December']` |
| `monthsShort` | `['Jan', …, 'Dec']` |
| `weekdays` | `['Sunday', …, 'Saturday']` |
| `weekdaysShort` | `['Sun', …, 'Sat']` |
| `weekdaysAbbrev` | `['S', 'M', 'T', 'W', 'T', 'F', 'S']` |

### Calendar keyboard navigation

The inline and docked calendars support keyboard navigation. Enter on a date input focuses the calendar. Tab enters or leaves its single day stop; arrow keys move by day or week. Left/Right follow `isRTL`. Home/End move to the first/last day of the week using `firstDay`. PageUp/PageDown change month, and Shift changes year, clamping to the last day of shorter months. Enter or Space selects an enabled day. Disabled dates remain focusable with `aria-disabled` but cannot be selected. A typed date that is outside the bounds, a disabled weekend or refused by `disableDayFn` puts the input back to the selected date. An input's initial value is the selection, and a form reset reads the selection back from the reset values. Navigation respects date and year/month bounds and restores day focus after redraws without moving focus from outside the calendar. A modal calendar moves focus to the selected day when it opens and back to the input when it closes.

### Date range

Set `isDateRange: true`. Click a start day, then an end day that is on or after it. Point `dateRangeEndEl` at a second input, or omit it and a second input is created next to the first.

A range with only an end date can be reopened safely. Clearing either input preserves the other date and removes the range shading until both dates are set.

An edit that would reverse the range restores that field's previous value. Each
input emits its own bubbling change event when the picker writes its value.
Generated inputs opt out of AutoInit, so repeated initialization does not create
another picker for them.

An authored end input supports typing and Enter-to-calendar navigation. Destroy
preserves authored inputs and removes generated inputs and their listeners.

```js
Expressive.Datepicker.init(document.getElementById('datepicker-range'), {
  openByDefault: true,
  isDateRange: true,
  dateRangeEndEl: '#datepicker-range-end'
});
```

### Multiple dates

Set `isMultipleSelection: true`. Click a day to add it; click it again to remove it. Each selected date gets its own input.

Defaults initialize the selected dates before drawing. Dates are compared at
local midnight. Removing the last selection or using Clear keeps the original
input empty; extra generated fields are removed. Destroy removes generated
fields and recovers focus on the original input if a removed field had focus
and application code has not moved it elsewhere.

```js
Expressive.Datepicker.init(document.getElementById('datepicker-multi'), {
  openByDefault: true,
  isMultipleSelection: true
});
```

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Datepicker.getInstance(elem);
```

The low-level `renderRow`, `renderBody`, and `renderTable` helpers accept only calendar markup: rows containing cells and day buttons. Use `renderDay` to create cells. Unsupported elements or attributes, including event handlers and styles, throw `TypeError`; day buttons require `type="button"`. These helpers no longer accept arbitrary custom HTML.

#### .toString();

String form of the selected date, using `format`. You can pass another date and format.

```text
instance.toString();
instance.toString(someDate, 'yyyy-mm-dd');
```

#### .setDate();

Select a date and move the calendar to it.

**Date (optional):** Date to select.

```text
instance.setDate(new Date());
```

#### .gotoDate();

Change the visible month without changing the selection.

**Date:** Date whose month should be shown.

```text
instance.gotoDate(new Date());
```

#### .destroy();

Destroy the plugin instance, remove the calendar, and tear down its event handlers.

```text
instance.destroy();
```

#### .open(); / .close();

These methods are not provided. The calendar defaults to inline presentation; `displayPlugin: 'docked'` enables docking and needs `openByDefault: true` to show the calendar. `displayPlugin: 'modal'` opens it in a dialog from the input.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The input the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `date` | Date | The selected date, or the range start. |
| `endDate` | Date | The range end, when `isDateRange` is true. |
| `dates` | Array | The selected dates when `isMultipleSelection` is true. |

---

## Time Picker

Pick a time from a clock face, in 12-hour or 24-hour form.

Add `time-picker` to a text input. `AutoInit()` starts every `.time-picker` except those marked `no-autoinit`.

The clock is inline, not a modal. It is appended to the input’s parent and stays visible. There is no `openByDefault` flag. `open()` and `close()` are not provided.

```html
<div class="field">
  <input type="text" class="time-picker" id="lunchtime">
  <label for="lunchtime">Lunchtime</label>
</div>
```

Wrap the input in its own small parent — a `.field`, or a bare `<div>`. The clock is appended inside that parent.

Click a hour on the dial, then a minute. With the default `autoSubmit: true`, finishing the minute writes the input (`HH:MM AM` or `HH:MM PM`). You can also type in the digital fields; Enter in any of them writes the typed time.

Digital edits use native input events, including paste. Invalid edits restore
the last valid value on blur. Empty fields use the current hour, minute or second without
changing the selected AM/PM period. Twelve-hour initialization converts 24-hour
strings and accepts either case for AM/PM. No modal or dial-keyboard API is added.

Clicking the host input or pressing Enter reloads its current value into the
clock. Changing the active clock field or editing its value cancels an older
delayed clock update.

The clock hand's canvas fades with `--md-sys-motion-duration-short3`, which defaults to 150ms. Override that token to change the CSS fade. The JavaScript `duration` option and its view and clock-reset timers stay separate; a CSS override does not reschedule them. Reduced motion disables the fade even with a nonzero token override.

### Initialization

The IIFE bundle exposes `Expressive.Timepicker`. Call `init` yourself when you need options other than the defaults, or let `Expressive.AutoInit()` start every `.time-picker`. Unlike Datepicker, the default options already show the clock.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.time-picker');
  const instances = Expressive.Timepicker.init(elems, {
    // specify options here
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  Timepicker: { twelveHour: false }
});
```

### Display

The clock is a `.timepicker-container` in the page, unless `displayPlugin: 'modal'` puts it in a dialog. There is no `autoClose` option.

`container` renders the clock into a specific element instead of the input’s parent. Pass a selector or a DOM node.

#### Docked popover

`displayPlugin: 'docked'` wraps the clock in a `.display-docked` popover that appears when the input is clicked or confirmed with Enter, and hides when you click outside.

```js
Expressive.Timepicker.init(elem, {
  displayPlugin: 'docked'
});
```

The popover stays inside the input’s parent and accounts for its positioning and scroll offsets. Positioned wrappers such as `.field` can be used directly.

Optional `displayPluginOptions`: `margin` (default `5`), `transition` (`10`), `duration` (`250`), and `align` (`'left'`).

#### Modal dialog

`displayPlugin: 'modal'` opens the clock in a modal `<dialog class="display-modal">`, drawn as a basic dialog over a scrim, when the input is clicked or confirmed with Enter. The dialog takes the input's label as its name and focus moves to the hour field. The time is written on Ok or on Enter in a field; Cancel, Escape or a press on the scrim close it without changing the input and call `onCancel`. Focus then returns to the input. A modal clock always shows Cancel and Ok, so it sets `autoSubmit: false` for you.

```js
Expressive.Timepicker.init(elem, {
  displayPlugin: 'modal'
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `duration` | Number | `350` | Transition between the hours and minutes views, in milliseconds. |
| `container` | Element or String | `null` | Element or selector to render the clock into. When omitted, it is appended to the input’s parent. |
| `showClearBtn` | Boolean | `false` | Show a Clear button in the footer. |
| `autoSubmit` | Boolean | `true` | If true, choosing a minute writes the input. If false, Ok and Cancel buttons are added. |
| `defaultTime` | String | `'now'` | Initial time. `'now'` or a `'HH:MM'` or `'HH:MM:SS'` string (optionally with `AM`/`PM`). |
| `fromNow` | Number | `0` | Millisecond offset added to `'now'`. |
| `i18n` | Object | See below | Labels for Cancel, Clear, Ok, Hours, Minutes and Seconds. |
| `twelveHour` | Boolean | `true`, or the language's | If true, use a 12-hour clock with AM/PM. If false, use 24-hour hours on two rings. With a `lang` on or around the input, defaults to the clock that language uses. |
| `showSeconds` | Boolean | `false` | Add a seconds field after the minutes and write `HH:MM:SS`. The dial still sets hours and minutes only. |
| `minTime` | String | `null` | The earliest time a person can choose, as 24-hour `HH:MM` or `HH:MM:SS`. Without `showSeconds` it rounds up to a whole minute. |
| `maxTime` | String | `null` | The latest time a person can choose, as 24-hour `HH:MM` or `HH:MM:SS`. Without `showSeconds` it rounds down to a whole minute. |
| `vibrate` | Boolean | `true` | Vibrate the device when the clock hand changes value. |
| `onSelect` | Function | `null` | Called when a time is chosen on the dial. Receives `(hour, minute)`. |
| `onInputInteraction` | Function | `null` | Called when the input is clicked or confirmed with Enter. |
| `onDone` | Function | `null` | Called once when Ok is activated by click, Enter or Space. The button is created when `autoSubmit` is false. |
| `onCancel` | Function | `null` | Called once when Cancel is activated by click, Enter or Space. The button is created when `autoSubmit` is false. |
| `displayPlugin` | String | `null` | Set to `'docked'` for a click-to-open popover, or `'modal'` for a dialog with Cancel and Ok. |
| `displayPluginOptions` | Object | `null` | Options for the docked plugin: `margin`, `transition`, `duration`, `align`. The modal plugin takes none. |

### Internationalization

Pass a partial `i18n` object. Missing keys keep the English defaults.

| Key | Default |
| --- | --- |
| `cancel` | `'Cancel'` |
| `clear` | `'Clear'` |
| `done` | `'Ok'` |

`i18n.hours`, `i18n.minutes` and `i18n.seconds` default to `Hours`, `Minutes` and `Seconds` and provide accessible input names. AM/PM buttons expose their selected state and support Enter and Space. The hour, minute and second fields are spin buttons: Up and Down step them by one and Page Up and Page Down by five, wrapping round, and the hand follows the hour and minute. Hours step round the day, so in 12-hour time passing 12 switches AM and PM: up from 11 AM comes 12 PM. Tab moves between controls. The dial itself takes pointer input only: the spin buttons already set the same values from the keyboard and to a screen reader, so a focusable dial would only add a second Tab stop for them.

### 12-hour and 24-hour

`twelveHour` defaults to `true`. The intro clock above is 12-hour with AM/PM. Set `twelveHour: false` for a 24-hour dial: hours 1–12 on the inner ring, 13–00 on the outer ring. The written value is then `HH:MM` with no meridian.

```js
Expressive.Timepicker.init(document.getElementById('timepicker-24'), {
  twelveHour: false
});
```

### Seconds

`showSeconds: true` adds a third field, a spin button named by `i18n.seconds`, after the minutes. It steps and takes typed values like the minute field, and an emptied field takes the current second. The written value becomes `HH:MM:SS`, with ` AM` or ` PM` on a 12-hour clock. A value read without seconds, such as `14:30`, opens at zero seconds. The dial keeps its hours and minutes views, as the Material 3 time picker does; seconds are set in the field only. Under `autoSubmit`, choosing a minute on the dial writes the time with the seconds the field shows.

```js
Expressive.Timepicker.init(document.getElementById('timepicker-seconds'), {
  showSeconds: true
});
```

A `type="time"` input gets 24-hour `HH:MM:SS`. Give it `step="1"` so the browser does not flag the seconds as a step mismatch.

```html
<input type="time" step="1" class="time-picker" id="alarm">
```

### Time range

`minTime` and `maxTime` bound the times a person can choose, as 24-hour `'HH:MM'` or `'HH:MM:SS'` strings. Either one alone bounds one end of the day. The range can't cross midnight: a `minTime` after `maxTime` logs a warning, and the picker ignores both. Without `showSeconds` the picker writes whole minutes, so `minTime` rounds up and `maxTime` rounds down to a minute.

An hour stays available while any minute of it is in range, so with 09:00 to 17:30 the hour 17 is available and its minutes stop at 30. Dial ticks outside the range get `is-disabled`, are dimmed, and the hand won't stop on them. A release on one changes nothing and fires no `select`. In the fields, Up and Down stop at the last allowed value instead of wrapping, a typed time outside the range is refused, and each field's `aria-valuemin` and `aria-valuemax` follow the allowed values. On a 12-hour clock, the AM or PM button is `disabled` when no time in that half of the day is allowed.

A value outside the range, in the input or `defaultTime`, opens at the nearest bound. So does a minute that an hour change leaves outside it: choosing 17 at 16:45 gives 17:30.

```js
Expressive.Timepicker.init(document.getElementById('timepicker-range'), {
  minTime: '09:00',
  maxTime: '17:30'
});
```

The picker doesn't check a value typed straight into the input. On a `type="time"` input, also set `min` and `max`, so the browser flags a value outside the range.

```html
<input type="time" min="09:00" max="17:30" class="time-picker" id="appointment">
```

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Timepicker.getInstance(elem);
```

#### .showView();

Show the hours or minutes face.

**String:** `'hours'` or `'minutes'`.

```text
instance.showView('hours');
```

#### .done();

Write the current time to the input, with seconds when `showSeconds` is on. Pass a truthy argument to clear the input instead.

```text
instance.done();
instance.done(true);
```

#### .clear();

Clear the input. Same as `done(true)`.

```text
instance.clear();
```

#### .destroy();

Destroy the plugin instance, remove the clock, and tear down its event handlers. Pending automatic submission and clock updates are cancelled, so the destroyed instance cannot write to the input.

```text
instance.destroy();
```

#### .open(); / .close();

These methods are not provided. The clock defaults to visible inline presentation; `displayPlugin: 'docked'` enables docking and `displayPlugin: 'modal'` opens it in a dialog from the input.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The input the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `time` | String | The last value written by `done()`, without the AM/PM suffix. It ends in `:SS` when `showSeconds` is on. |
| `hours` | Number | The hour currently shown on the clock. |
| `minutes` | Number | The minute currently shown on the clock. |
| `seconds` | Number | The second of the time on the clock. The seconds field shows it when `showSeconds` is on. |
| `amOrPm` | String | `'AM'` or `'PM'` when `twelveHour` is true. |
| `currentView` | String | `'hours'` or `'minutes'`. |

---

## Keypad and Keyboard

On-screen keys that type into a text field: a PIN or dial pad, or a full keyboard.

A `.keypad` is three columns of round 72dp keys for PINs, codes and amounts. A `.keyboard` is a surface of rows, each child a row of keys, for text. Both are a `role="group"` with an `aria-label`, and `aria-controls` names the `<input>` or `<textarea>` they type into. Every key is a `<button type="button">`. A key with a `value` types it at the caret, replacing any selection, and the field gets the same `input` event typing would fire. `AutoInit()` starts every `.keypad` and `.keyboard`.

```html
<div class="field outlined">
  <input id="pin" type="password" inputmode="none" maxlength="6" placeholder=" " autocomplete="off">
  <label for="pin">PIN</label>
</div>

<div class="keypad" role="group" aria-label="PIN keypad" aria-controls="pin">
  <button type="button" value="1">1</button>
  <button type="button" value="2">2</button>
  <!-- … 3 to 9 … -->
  <span></span>
  <button type="button" value="0">0</button>
  <button type="button" data-key="Backspace" aria-label="Delete">
    <span class="material-symbols" aria-hidden="true">backspace</span>
  </button>
</div>

<div class="keyboard" role="group" aria-label="Keyboard" aria-controls="message">
  <div class="numbers"><button type="button" value="1">1</button><!-- … 2 to 0 … --></div>
  <div><button type="button" value="q">q</button><!-- … --></div>
  <div><!-- a to l --></div>
  <div>
    <button type="button" data-key="Shift" aria-pressed="false" aria-label="Shift">
      <span class="material-symbols" aria-hidden="true">shift</span>
    </button>
    <!-- z to m -->
    <button type="button" data-key="Backspace" aria-label="Delete">
      <span class="material-symbols" aria-hidden="true">backspace</span>
    </button>
  </div>
  <div>
    <button type="button" value=" " aria-label="Space"></button>
    <button type="button" data-key="Enter" aria-label="New line">
      <span class="material-symbols" aria-hidden="true">keyboard_return</span>
    </button>
  </div>
</div>
```

- `data-key="Backspace"` deletes the selection or the character before the caret. `data-key="Enter"` calls `requestSubmit()` on the field's form, so validation runs, or types a new line in a `<textarea>`. `data-key="Shift"` toggles `aria-pressed`; while pressed the character keys show capitals, and the next one types a capital and releases Shift. `data-key="CapsLock"` toggles `aria-pressed` and types capitals until pressed again. `data-key="Layer"` with `value="symbols"` shows every element in the keyboard whose `data-layer` is `symbols` and hides those with another `data-layer`, so a row of symbols can replace the letters; elements without `data-layer` always show. Any other `data-key` only calls `onKey`. While the field has focus, a hardware key held down gives the matching on-screen key the `pressed` class, which presses it as a pointer would.
- Typing stops at the field's `maxlength`. Browsers check `minlength` only on what a person types, and a keypad's edit is a script's, so the keypad checks it: a non-empty value shorter than `minlength` gets the custom validity message from `i18n.tooShort`, "Use at least 3 characters." by default, and loses it once long enough. The keypad never replaces a message the page set, and a form reset or `destroy()` clears its own. `pattern`, `required` and the type checks hold for keypad edits, and Enter submits with `requestSubmit()`, so they run. Keys type at the caret; a field nobody has clicked into takes them at the end. Number and email inputs have no caret position, so keys type at the end. A button with neither `value` nor `data-key` is not a key and is ignored. A number input empties itself on a partial value such as `12.` or `-`, so the key is ignored there; use a text field with `inputmode="none"` for decimals and signs.
- A dial-pad key puts its letters in `<small aria-hidden="true">` after the digit. A key with a button style class (`filled`, `tonal`, `outlined`, `elevated`, `text`) keeps that style. Keys fill their cell, and `wide` spans two as a pill, so a call key beside Backspace fills the last row:

```html
<button type="button" class="filled wide" data-key="Enter" aria-label="Call">
  <span class="material-symbols" aria-hidden="true">call</span>
</button>
```

- An empty `<span>` holds a keypad cell open. For a centered call key, put Backspace in the field as a trailing `<button class="icon-button suffix" data-key="Backspace">`: a `data-key` key inside the field's `.field` works like a keypad key, even when the field renders after the keypad, and runs once however many keypads share the field. Other buttons there, such as a password toggle, stay their own. An unknown `data-key`, such as `Contacts`, types nothing and calls `onKey`, so the empty cell can hold a page action.
- On a keyboard a character key is a tenth of the row and may shrink, a function key is one and a half keys wide, and the space bar takes the rest. A nine-key row is centered, which gives QWERTY its half-key indent. `--md-comp-keyboard-columns` sets the column count. An optional `<div class="numbers">` row of digits above the letters has shorter 40dp keys; leave it out, or toggle `hidden` on it.
- Keys square their corners on press with the button-group spring. Reduced motion drops the shape transition.
- Add `inputmode="none"` to the field so a phone keeps its own keyboard closed. A pointer press keeps focus and the caret in the field while it has focus.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-keypad-key-size` | 72px |
| `--md-comp-keypad-gap` | 12px |
| `--md-comp-keypad-key-color` | `surface-container-highest` |
| `--md-comp-keypad-key-label-color` | `on-surface` |
| `--md-comp-keypad-function-key-color` | `secondary-container` |
| `--md-comp-keypad-function-key-label-color` | `on-secondary-container` |
| `--md-comp-keypad-key-pressed-shape` | 12px; keyboard: 6px |
| `--md-comp-keyboard-columns` | 10 |
| `--md-comp-keyboard-gap` | 6px |
| `--md-comp-keyboard-key-height` | 48px |
| `--md-comp-keyboard-number-key-height` | 40px |
| `--md-comp-keyboard-key-shape` | 12px |
| `--md-comp-keyboard-container-color` | `surface-container` |
| `--md-comp-keyboard-container-shape` | 16px |

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `onKey` | Function | `null` | Called after every key press with the text typed or the function key's name (`'Backspace'`, `'Enter'`, `'Shift'`, `'CapsLock'`, `'Layer'`). Works without `aria-controls`. |
| `i18n.tooShort` | Function | `(minLength) => 'Use at least ' + minLength + ' characters.'` | The validation message for a keypad-typed value shorter than the field's `minlength`. |

### Methods

#### .destroy()

Stops the keypad typing. The shared listeners on the document or shadow root go with the last keypad there.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `target` | Element | The field named by `aria-controls`, or `null`. |

---

## Text Inputs

Material Design 3 text fields, from the HTML.

A `.field` is the container. The `<label>` after the control is the floating label, so it must carry `for` — it sits after the control and cannot wrap it. An icon names its side with `prefix` or `suffix` and is `aria-hidden`, because the ligature is real text. `<small>` is supporting text: give it an `id` and point the control at it with `aria-describedby`, or it is never read out with the field. The default is the M3 filled field; add `outlined` (or `border`) for the outlined variant.

`.input-field` is **not** the container and never was — the only rule matching it in the sheet is `.chips.input-field`.

Tokens follow the [M3 text field spec](https://m3.material.io/components/text-fields/specs). Height is 56dp. Filled is a `surface-container-highest` well with 4dp top corners and a 1dp / 2dp bottom indicator. Outlined is a 1dp / 2dp `outline` at 4dp. The label is `body-large` at rest and `body-small` floated. Input text is `body-large` / `on-surface`. Icons are 24dp, 12dp from the edge.

Add `placeholder=" "` (one space) so the label floats with CSS only. A missing placeholder, or any other string, keeps the label floated. Importing the bundle runs `Forms.Init()`; it validates `.validate` fields on `change` and starts textareas and file paths. Character Counter is not in `AutoInit()`.

```html
<div class="field">
  <input id="first_name" type="text" placeholder=" " aria-describedby="first_name_help">
  <label for="first_name">First name</label>
  <small id="first_name_help">Supporting text</small>
</div>

<div class="field outlined">
  <input id="last_name" type="text" placeholder=" ">
  <label for="last_name">Last name</label>
</div>

<div class="field">
  <span class="material-symbols prefix" aria-hidden="true">place</span>
  <span class="material-symbols suffix" aria-hidden="true">gps_fixed</span>
  <input id="loc" type="text" placeholder=" ">
  <label for="loc">Location</label>
</div>
```

Put `invalid` or `aria-invalid="true"` on the input for the error state. Do not put `error` on the wrapper — that class is a color utility and fills the field.

A field icon needs a side. `.prefix` is leading and `.suffix` is trailing; an icon without either is not positioned.

### Input types

`email`, `password`, and the other native text-like types are styled the same way. `validate` uses HTML5 constraint validation on `change` and toggles `invalid` and `aria-invalid="true"` on the input, so a screen reader hears the error state too. It adds the field's `[data-error]` supporting text to the input's `aria-describedby`, giving it an id if it has none and keeping the ids already there, so the error the CSS shows is read with the field. There is no green `valid` style.

```html
<div class="field">
  <input id="email" type="email" class="validate" placeholder=" " aria-describedby="email_help">
  <label for="email">Email</label>
  <small id="email_help" data-error="Enter a valid email">Supporting text</small>
</div>
```

`validate` also honors `data-length`: the field is marked `invalid` when the value is longer than that number. Prefer `maxlength` when you want the browser to cap input.

### Inline

Add `inline` to sit the field in a line of text.

```html
This is an inline input field:
<span class="field inline">
  <input id="email_inline" type="email" class="validate" placeholder=" ">
  <label for="email_inline">Email</label>
</span>
```

### Textarea

Use `textarea.expressive-textarea` inside `.field`. Textareas grow with their content. `Forms.Init()` starts every `.expressive-textarea` on `DOMContentLoaded`.

```html
<div class="field">
  <textarea id="textarea1" class="expressive-textarea" placeholder=" "></textarea>
  <label for="textarea1">Textarea</label>
</div>
```

If you add a textarea after load, initialize it yourself:

```js
Expressive.Forms.InitTextarea(document.querySelector('#textarea1'));
```

Setting `.value` in script does not resize the field. Call `textareaAutoResize` afterwards.

### Trailing button

A `.icon-button` with `suffix` sits at the end of the field as a control: it keeps its 40dp target and takes clicks, where a trailing icon lets them through to the input. Name it with `aria-label`.

### Password visibility

Add `password-toggle` to a trailing icon button with two icons and `aria-pressed="false"`. Pressing it shows the password as text and sets `aria-pressed="true"`; the first icon shows while the password is hidden and the second once it is shown. The name stays "Show password", and the pressed state tells a screen reader which way it is. The Forms behavior does the switch, and hides the password again when its form is submitted or reset.

```html
<div class="field">
  <input id="password" type="password" placeholder=" " autocomplete="current-password">
  <label for="password">Password</label>
  <button type="button" class="icon-button suffix password-toggle" aria-label="Show password" aria-pressed="false">
    <span class="material-symbols" aria-hidden="true">visibility</span>
    <span class="material-symbols" aria-hidden="true">visibility_off</span>
  </button>
</div>
```

### Number stepper

Two trailing icon buttons with `data-step="down"` and `data-step="up"` step a number input, within its `min`, `max` and `step`, the way the arrow keys do. Each press fires `input` and `change`. The browser's own spin buttons are hidden, and a disabled, read-only or `step="any"` input is left alone. The Forms behavior does the stepping. Name each button with what it changes.

```html
<div class="field">
  <input id="quantity" type="number" placeholder=" " value="1" min="1" max="10">
  <label for="quantity">Quantity</label>
  <button type="button" class="icon-button suffix" data-step="down" aria-label="Decrease quantity">
    <span class="material-symbols" aria-hidden="true">remove</span>
  </button>
  <button type="button" class="icon-button suffix" data-step="up" aria-label="Increase quantity">
    <span class="material-symbols" aria-hidden="true">add</span>
  </button>
</div>
```

### One-time code

`input.otp` draws one native input as a row of six cells, so paste, autofill and undo keep working and a screen reader hears one field. Use `type="text"` with `inputmode="numeric"`, `autocomplete="one-time-code"` and a `maxlength` equal to the cell count, and label it outside any `.field`. Set `--md-comp-otp-length` for another length. Digits read left to right in every language.

```html
<label for="code">Verification code</label>
<input id="code" class="otp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
```

| Token | Default |
| --- | --- |
| `--md-comp-otp-length` | 6 |
| `--md-comp-otp-cell-width` | 48px |
| `--md-comp-otp-cell-height` | 56px |
| `--md-comp-otp-gap` | 8px |
| `--md-comp-otp-cell-color` | `surface-container-highest` |
| `--md-comp-otp-indicator-color` | `on-surface-variant`; focus: `primary`; `invalid`: `error` |

### File input

A `.file-field.field` pairs a button with a path field. `Forms.Init()` copies the chosen file name into `input.file-path`.

The trigger is a `<label>` **wrapping** the file input, not a `<button>` wrapping it. The distinction is not pedantry: `<button>` forbids interactive content, so a control inside it is invalid and yields two overlapping hit targets, whereas a `<label>` containing its own control is the pattern the spec is written around. Wrapping also means the label is what the invisible input is sized against, so the picker opens from the button and nowhere else. The path field is `readonly` — it only ever shows what the file input holds, so offering an edit that is thrown away is a lie — and takes an `aria-label` because it has no visible label of its own.

```html
<div class="file-field field">
  <label class="button">
    File
    <input type="file">
  </label>
  <div class="file-path-wrapper">
    <input class="file-path" type="text" placeholder=" " readonly aria-label="Selected file">
  </div>
</div>
```

Add `multiple` to allow more than one file.


### Color

An `<input type="color">` keeps the browser's picker and is drawn as a 40px round swatch of its color in a 48px target, with the shared focus ring. Label it like any field and keep it outside a `.field`. Tokens on the input: `--md-comp-color-input-size` (40px) and `--md-comp-color-input-outline-color` (`outline`).

```html
<label for="accent">Accent color</label>
<input id="accent" type="color" value="#c6a0f6">
```

### Character counter

Character Counter is not in `AutoInit()`. It reads `maxlength` (not `data-length`) and writes `current/max` into a `.character-counter` span. Overflow adds `invalid` on the field.

```js
Expressive.CharacterCounter.init(
  document.querySelectorAll('#input_text_counter, #textarea_counter')
);
```

---

## Input mask

Formats a text field's value to a pattern, such as a phone or card number, as people type, paste or compose.

Add `data-mask` to an `<input>` of type `text`, `tel`, `search`, `url` or `password`. `AutoInit()` starts every one. In the mask, `0` takes a digit, `a` takes a letter and `*` takes either. Every other character is a literal the mask writes itself, and a backslash makes the next character a literal, so `\0` writes a zero.

As a person types, each character that fits fills the next slot, and the mask writes the literals that come before it: typing `5551` into `(000) 000-0000` shows `(555) 1`. A character that fits no slot is dropped, as is one past the end. A paste or autofill keeps the characters that fit and skips the rest, so `555.123.4567` becomes `(555) 123-4567`. A typed character matches only the literal that comes next, so in a `+1 (000) 000-0000` mask a typed leading 1 fills the first slot; a paste or autofill that starts with `+1` matches the prefix. Leave a country code out of the mask, or say in the supporting text not to type it. A mask with no slot leaves the field alone. The caret stays after the last character the edit added. Backspace right after a literal, or Delete right before one, removes the digit across it. Full-width digits from an IME count as digits, and while an IME composes, the field waits and formats once the composition ends.

The form submits the value as the field shows it, `(555) 123-4567`, and `pattern`, `required` and the length checks run on that value. A saved value is formatted when the component starts, without an `input` or `change` event, and again after a form reset. The component formats before the page's own `input` listeners run, so they read the formatted value. A value a script sets is formatted once the script fires an `input` event. Keypad keys format like typing; with the caret right after a literal, the Keypad's Backspace moves the caret before the literal, and a second press deletes the digit.

Add `inputmode="numeric"` to a digit-only mask so phones open a number pad, and show the expected format in the supporting text. In a right-to-left page, add `dir="ltr"` to a phone or card number field. After a formatted edit, undo does not step back in Chromium, Firefox or WebKit, because setting the value clears the field's undo history. Android keyboards can treat ordinary typing as composition, so a letter mask there may format only when a word ends; a number pad does not compose.

```html
<div class="field">
  <input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel-national" placeholder=" " data-mask="(000) 000-0000" aria-describedby="phone_help">
  <label for="phone">Phone</label>
  <small id="phone_help">Format: (555) 123-4567</small>
</div>
```

### Methods

#### .destroy()

Removes the listeners and leaves the value as it is.

---

## Currency input

Shows a typed amount in the page's locale and currency once the field loses focus, and submits the plain number.

Add `data-currency` with an ISO 4217 code, such as `EUR`, to an `<input>` of type `text`. `AutoInit()` starts every one. The locale comes from the nearest `lang`, or the browser's language without one. A code `Intl.NumberFormat` rejects leaves the field a plain text input.

When the field loses focus, it shows the amount rounded to the currency's fraction digits: under `lang="de"` with `EUR`, typing `1234.5` shows `1.234,50 €`. A page's `change` and `blur` listeners read the formatted text. When the field gains focus, it shows the plain number in the locale's decimal mark, without grouping or sign, such as `1234,5`, with the caret at the end.

Typed and pasted text keeps its digits, `.`, `,` and a leading `-` or `−`, and drops anything else, such as spaces, signs and letters. The last `.` or `,` is the decimal mark when it is followed by no more digits than the currency shows; every other separator groups. So `1234.5`, `1234,5`, `1.234,5` and `1,234.50` all read 1234.5, `1.234` reads 1234 for euros, and with yen, which has no fraction digits, every separator groups. Full-width digits from an IME and the locale's own digits read as digits. Text with no digit stays as typed, and the field reports itself invalid with `Enter an amount.` until it holds an amount or is empty, so `.validate` shows the message.

The form submits the plain number, `1234.5`, through a `formdata` listener, so a submit and `new FormData(form)` read the same value, also when the form is submitted with Enter while the field has focus. The field keeps its `name`. An empty field submits an empty string, and text with no digit submits as typed. A saved value is formatted when the component starts, without an `input` or `change` event, and again after a form reset. It reads as a plain number first, so `value="1.234"` is 1.234; write saved amounts that way. A value a script sets shows formatted after the field next loses focus. `min`, `max` and `step` do not apply to a text input; check the `value` property instead.

Add `inputmode="decimal"` so phones open a number pad with a decimal key, and name the currency in the label. Do not combine `data-currency` with `data-mask` on one field.

```html
<div class="field">
  <input id="price" name="price" type="text" inputmode="decimal" autocomplete="off" placeholder=" " data-currency="EUR">
  <label for="price">Price in euros</label>
</div>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `i18n` | Object | `{ invalid: 'Enter an amount.' }` | `invalid` is the validity message for text that holds no amount. Markup sets it with `data-currency-input-options='{"i18n":{"invalid":"Betrag eingeben."}}'`. |

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `value` | Number or null | The amount the field holds, rounded as it shows, or `null` when it is empty or holds no digit. |

### Methods

#### .destroy()

Removes the listeners, clears the validity message and leaves the text as it is.

---

## Rich text editor

A text field with a formatting toolbar for writing headings, lists and styled text, built on Tiptap.

A `.rich-text-editor` holds a docked `.toolbar` and a `<textarea>` whose value is the starting HTML. The component hides the textarea, mounts the editor in a `.rich-text-editor-content` after it, and writes the editor's HTML back on every change, so the textarea submits with its form and fires `input`. An empty editor submits an empty value. Without JavaScript the textarea stays visible and the toolbar is hidden.

ExpressiveCSS does not bundle Tiptap. Install `@tiptap/core`, `@tiptap/pm` and `@tiptap/starter-kit`, and pass the `Editor` class and extensions to `RichTextEditor.init()`. `AutoInit()` does not start the editor.

A button's `data-command` names a Tiptap command and `data-attributes` holds its argument as JSON. A click runs the command and returns focus to the editor. A button is disabled while its command cannot run. Buttons for `toggle` commands set `aria-pressed` and `.active` while the selection has that format: `toggleBold` while it is bold, `toggleHeading` with `{"level":2}` while it is in a second-level heading.

The editable area is a multi-line `textbox` named from the textarea's `<label for>`, `aria-label` or `aria-labelledby`; a click on the label focuses it. A `disabled` or `readonly` textarea makes the editor read-only and disables the toolbar. Sanitize the submitted HTML on the server.

```html
<label for="body">Release notes</label>
<div class="rich-text-editor">
  <div class="toolbar docked">
    <button type="button" class="circle" data-command="toggleBold" aria-label="Bold">
      <span class="material-symbols" aria-hidden="true">format_bold</span>
    </button>
    <button type="button" class="circle" data-command="toggleHeading"
            data-attributes='{"level":2}' aria-label="Heading">
      <span class="material-symbols" aria-hidden="true">format_h2</span>
    </button>
    <span class="max"></span>
    <button type="button" class="circle" data-command="undo" aria-label="Undo">
      <span class="material-symbols" aria-hidden="true">undo</span>
    </button>
  </div>
  <textarea id="body" name="body"><p>Starting content</p></textarea>
</div>
```

```js
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { RichTextEditor } from '@expressivecss/expressive';

RichTextEditor.init(document.querySelector('.rich-text-editor'), {
  Editor,
  extensions: [StarterKit]
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `Editor` | Class | `null` | Tiptap's `Editor` class. Required. |
| `extensions` | Array | `[]` | Tiptap extensions, usually `[StarterKit]`. |

### Methods

#### .editor

The Tiptap editor, for anything the toolbar does not cover.

#### .destroy()

Destroys the editor, removes its element and the button state, and shows the textarea again.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-rich-text-editor-min-height` | 160px |
| `--md-comp-rich-text-editor-shape` | 16px |

---

## Drop zone

A large target for choosing or dropping files, with an optional list and independent type, size and count limits.

A `.drop-zone` holds a `<label>` with an icon, a prompt and a native `<input type="file">`, and an optional `<ul class="drop-zone-files">` after it. The input covers the label, so clicking, keyboard input and file dropping reach the native control. Forms marks dragover and lists each selected file's name and localized size. The root bundle installs Forms on load; modular consumers call `Forms.Init()`. Document-level input/change handling also supports dynamically inserted zones and open shadow roots.

A dropped file replaces the selection; add multiple to take several files. `accept` alone is a picker hint. Add data-validate-types for type checks, data-max-size for a per-file byte limit or data-max-files for a count limit. Unmarked zones retain their existing behavior. Reset clears lists after dispatch/default handling; cancellation preserves the current view, and a newer selection keeps its state.

```html
<div class="drop-zone">
  <label>
    <span class="material-symbols" aria-hidden="true">upload_file</span>
    Drop files here or browse
    <small>PDF or PNG files</small>
    <input type="file" multiple accept=".pdf,.png">
  </label>
  <ul class="drop-zone-files" aria-live="polite"></ul>
</div>
```

### Type validation

Add `data-validate-types` to the native file input inside its zone. Each file must match at least one supported comma-separated accept token. Matching trims tokens and ignores case: extensions such as `.pdf` or `.tar.gz`, exact MIME types without parameters such as `application/pdf`, and the media wildcards `image/*`, `audio/*` and `video/*`. Other whole-subtype wildcards are unsupported. Ignore unsupported/malformed tokens; missing, empty or wholly unsupported accept values impose no type restriction. An empty MIME type can match an explicit extension, but never a MIME-only rule. Do not infer MIME from the filename.

```html
<div class="drop-zone">
  <label>Drop documents here or browse
    <small>PDF or PNG files</small>
    <input type="file" multiple data-validate-types accept=".pdf,.png">
  </label>
  <p class="supporting-text" data-error="Choose PDF or PNG files."
     role="status" aria-live="polite" hidden></p>
  <ul class="drop-zone-files" aria-live="polite"></ul>
</div>
```

Supply a direct zone-level data-error status area even without a file list. Reserve its contents for runtime messages. The nonempty data-error value localizes the type-rejection message; an empty value uses a message naming accept. The runtime writes real text, creates an id when needed and appends it once to aria-describedby after existing authored descriptions. It updates invalid/aria-invalid without moving focus. Error styling uses the existing error and supporting-text tokens.

One failing file invalidates the whole selection through native custom validity. Keep every file in the native FileList and rendered list; no subset is accepted, removed or uploaded. A pre-existing unowned application custom error is preserved instead of overwritten. The status can still explain the type mismatch separately. With no type mismatch, it reflects remaining native required/application errors or clears when valid. Optional empty choices are valid; required-empty native invalidity stays effective. Ordinary validated forms block submission; existing error summaries and native novalidate/formnovalidate behavior remain unchanged.

Each changed type-rejected selection reports one bubbling, composed, noncancelable `expressive:reject` from its nearest wrapper with `{ input, files, rejected, reason: 'type' }`. The arrays are independent snapshots containing File objects. A native input/change pair reports once for unchanged files/constraints; a genuinely new selection or changed effective constraint can report again. This is a notification, not a request that can permit invalid files. A listener may replace the selection and dispatch its new input without an older handler restoring stale state.

```js
zone.addEventListener('expressive:reject', ({ detail }) => {
  console.log(detail.reason, detail.rejected.map(file => file.name));
});
// Silent validity/feedback refresh after scripted files or constraint changes:
Expressive.Forms.validateField(input);
// To refresh the file list too, dispatch one bubbling composed input or change:
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
```

Valid replacement, clearing, removal of the opt-in followed by validation, and an uncanceled reset release only framework-owned type validity. If the application replaces that message, recovery preserves its new value. Reset uses native form ownership, including controls associated through a form attribute outside the form subtree. Cleanup runs after dispatch/default handling, respects cancellation and leaves newer selections intact. Reset and silent validation emit no rejection event. Disabled controls and fieldsets remain inactive, including reset-time validity writes; call Forms.validateField after scripted enabling to refresh the current selection. Handled shadow-root forms follow the same reset contract.

Uploads are outside these options. File names and MIME labels do not verify contents. Native validation can be bypassed by scripts or explicit form settings; always validate received data on the server.

### Per-file size limit

Add `data-max-size="1048576"` to the native input for a maximum of 1,048,576 bytes (1 MiB) per file. This is independent of data-validate-types; accept remains a picker hint in a size-only zone. A file exactly at the limit passes; one oversized file invalidates the whole selection while retaining every File and list row. There is no combined-size limit.

```html
<div class="drop-zone">
  <label>Drop files here or browse
    <small>At most 1 MiB per file</small>
    <input type="file" multiple data-max-size="1048576">
  </label>
  <p class="supporting-text" data-error
     data-error-size="Choose files no larger than 1 MiB each."
     role="status" aria-live="polite" hidden></p>
  <ul class="drop-zone-files" aria-live="polite"></ul>
</div>
```

Use positive decimal safe-integer bytes, with surrounding whitespace allowed. Missing, blank, zero, negative, fractional, exponent, hexadecimal, nonnumeric or unsafe values impose no size constraint. Leading zeros are allowed. Remove the attribute or change its value and call Forms.validateField to silently refresh validity and feedback; dispatch a bubbling composed input/change to refresh the list too.

Combine both constraints on one input: `type="file" multiple data-validate-types accept=".pdf,.png" data-max-size="1048576"`. Supply the same direct polite data-error status area. A nonempty data-error-size value localizes size feedback; empty uses a message naming the exact byte limit. Existing data-error continues localizing type feedback. Type failures take precedence; only when all files match types does the size failure appear. With neither file failure, remaining native/application errors are shown or feedback clears when valid. Existing application custom validity is never overwritten.

A changed size-rejected selection reports the same bubbling, composed, noncancelable `expressive:reject` with `{ input, files, rejected, reason: 'size' }`. Files contains the full selection; rejected contains only oversized files. Type failures retain reason:'type' and their type-rejected subset. Native input/change pairs deduplicate using File identities, the type opt-in, raw accept and effective parsed byte/count limits. Equivalent decimal limits do not report again; a changed effective constraint can report after an input/change event. Event arrays are independent snapshots, silent validation/reset report nothing, and newer reentrant selections stay authoritative.

Valid replacement, clearing, removing/invalidating the size limit and successful reset release only owned file validity. Application messages, canceled reset, disabled-control refresh, native form association, shadow forms and newer-selection protection follow the type-validation recovery contract above. Ordinary native submission is blocked while invalid; native bypass settings remain native. Always enforce file type/size and contents on the server.

### File count

Add `data-max-files="2"` to the native input for at most two selected files. Exactly two pass; more invalidate the whole retained selection. Each native FileList entry counts, including repeated filenames. This is independent of type/size validation. A count-only input leaves accept as a picker hint, and data-max-files does not add multiple automatically.

```html
<div class="drop-zone">
  <label>Drop files here or browse
    <small>At most 2 files</small>
    <input type="file" multiple data-max-files="2">
  </label>
  <p class="supporting-text" data-error
     data-error-count="Choose no more than two files."
     role="status" aria-live="polite" hidden></p>
  <ul class="drop-zone-files" aria-live="polite"></ul>
</div>
```

Use trimmed positive decimal safe-integer counts, including leading zeros. Missing, blank, zero, negative, fractional, exponent, hexadecimal, nonnumeric or unsafe limits impose no count constraint. Remove/change the attribute and call Forms.validateField to silently refresh validity/feedback; a bubbling composed input/change also refreshes the list.

Combine all three constraints on one input: `type="file" multiple data-validate-types accept=".pdf,.png" data-max-size="1048576" data-max-files="2"`. Keep the direct polite data-error status area. Nonempty data-error-count localizes count feedback; empty uses a message naming the count limit. Existing data-error and data-error-size continue localizing type/size messages. Type failures take precedence, followed by size and then count. Preserve unowned application custom validity even while the status explains a file-rule failure.

Count rejection reports the existing bubbling, composed, noncancelable expressive:reject with `{ input, files, rejected, reason: 'count' }`. Both arrays contain every selected File as independent snapshots. No first-N subset is accepted, removed or uploaded. Earlier type/size failures retain their existing reasons and rejected subsets. Native pairs deduplicate using File identities, the type opt-in, raw accept and effective parsed byte/count limits; equivalent count values do not notify again. A changed effective count can report after input/change. Silent validation/reset emit no rejection, event-array mutation cannot corrupt metadata, and newer reentrant selections remain authoritative.

Valid/exact-count replacement, clearing, count opt-out and successful reset release only owned validity. Application errors, canceled reset, disabled-control refresh, native form ownership, handled shadow forms and newer-selection protection follow the shared recovery contract above. Required-empty and native bypass settings remain effective. Never filter the native FileList to satisfy the limit. Validate received counts, types, sizes and file contents on the server.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-drop-zone-shape` | 16px |
| `--md-comp-drop-zone-outline-color` | `outline` |
| `--md-comp-drop-zone-dragover-container-color` | `primary-container` |
| `--md-comp-drop-zone-dragover-color` | `on-primary-container` |

---

## Fieldsets

Grouped form sections, from the HTML.

A `<fieldset>` is the group. A `<legend>` is the headline. Everything else is a field — `.field`, radios, switches, a `.inline` row of radios, a trailing `<small>` as supporting text. There is no wrapper class. They are CSS only. There is no JavaScript component and nothing to AutoInit.

Material Design 3 has no fieldset component. Tokens follow the outlined grouping container used around related form content, and the [outlined text-field](https://m3.material.io/components/text-fields/specs) shape so a group of fields matches the fields. The container is 4dp corners and a 1dp `outline-variant` stroke. The legend is `title-small` / `on-surface`. Supporting text is `body-small`. Padding is 16dp; children sit 16dp apart. Disabled is 38%.

```html
<fieldset>
  <legend>Name</legend>
  <div class="field">
    <input id="first" type="text" placeholder=" ">
    <label for="first">First name</label>
  </div>
  <div class="field">
    <input id="last" type="text" placeholder=" ">
    <label for="last">Last name</label>
  </div>
</fieldset>
```

### Choices

A fieldset is also the right parent for a radio, checkbox, or switch group — the legend names the question, the `disabled` attribute disables every control inside. Put the labels in a `<div class="inline">` to sit them on one line.

```html
<fieldset>
  <legend>Notify me about</legend>
  <label class="switch">
    <input type="checkbox" checked>
    Comments
  </label>
  <label class="switch">
    <input type="checkbox">
    Mentions
  </label>
  <small>You can change these later in settings.</small>
</fieldset>

<fieldset>
  <legend>Plan</legend>
  <div class="inline">
    <label>
      <input name="plan" type="radio" checked>
      Monthly
    </label>
    <label>
      <input name="plan" type="radio">
      Yearly
    </label>
  </div>
</fieldset>
```

### Variants

The default is outlined. `filled` is a `surface-container-highest` well with no stroke — pair it with `outlined` fields so the wells stay distinct. `rounded` is 12dp corners (M3 medium, like a card). `outlined` and `border` name the default if you need to say it.

`plain` drops the container for a group that already sits on a surface, such as radios in a card or a dialog: no outline, fill, padding, or margin from a preceding fieldset, and the legend starts flush with the content.

```html
<fieldset class="plain">
  <legend>Delivery</legend>
  <label><input type="radio" name="delivery" value="daily" checked>Daily</label>
  <label><input type="radio" name="delivery" value="weekly">Weekly</label>
</fieldset>
```

```html
<fieldset class="filled">
  <legend>
    <span class="material-symbols" aria-hidden="true">place</span>
    Address
  </legend>
  <div class="field outlined">…</div>
</fieldset>

<fieldset class="filled rounded">
  <legend>Preferences</legend>
  …
</fieldset>
```

### Disabled

The HTML `disabled` attribute on a fieldset disables every control inside it. The legend and outline drop to 38%.

```html
<fieldset disabled>
  <legend>Billing</legend>
  <div class="field">…</div>
</fieldset>
```

---

## Questionnaire

A form that asks one question at a time, with single and multiple choice, free text and skippable questions.

A `<form class="questionnaire">` holds one `<fieldset>` per question and a `<footer>` of buttons. The `<legend>` is the prompt and a `<p>` under it describes the question. Answers are ordinary radio buttons, checkboxes or text fields; a `<small>` inside a choice's label describes that choice, and choices draw as outlined rows that fill with `secondary-container` when selected. A `<p class="questionnaire-error">` in a question holds its error text. The footer buttons are marked by `value`: `previous`, `skip` and `next`, each `type="button"`, plus a submit button. `AutoInit()` starts every `form.questionnaire`.

The component adds a progress row ("Question 1 of 3" and a `<progress>` named by that text), hides every question but the current one with `hidden`, and shows only the buttons that apply: Back after the first question, Skip on an optional question, Next before the last one and Submit on the last. A question is required when one of its controls is `required`; `data-required` on a fieldset of checkboxes requires at least one. Next checks the shown question with the browser's validation. When it fails, the fieldset gets `invalid`, its controls get `aria-invalid="true"`, the error shows and joins the description in the fieldset's `aria-describedby`, and focus moves to the control to fix. Answering clears it. A question without its own error shows the browser's validation message, or `requiredMessage`. Skip clears the shown answer, firing `change`, and moves on. Enter in a field moves to the next question, and on the last one submits. Submitting, with the button, Enter or `requestSubmit()`, checks every question and opens the first that still needs an answer. Only enabled checkboxes count toward `data-required`, since a disabled one is not sent. After Next, Back or Skip, focus moves to the selected answer or the first control.

A disabled fieldset is skipped and left out of the count, so a page branches by enabling or disabling questions from earlier answers. The form submits normally: read the answers with `FormData`, using `getAll()` for checkboxes. Without JavaScript every question shows at once, the navigation buttons stay hidden and the submit button sends the form; a `data-required` group is not checked then. Name the form with `aria-label` or `aria-labelledby`.

`data-shortcuts` on the form lets a letter or digit pick the answer at that place in the shown question (A or 1 for the first enabled choice) and ends each choice with its letter, hidden from screen readers; keys typed into a text field stay text, and keys with Ctrl, Alt or Meta pick nothing. `data-summary` adds a review step after the last question: a `.questionnaire-summary` lists each question that applies, its answer (or `skippedLabel`) and an Edit button back to it, and the submit button shows there instead of on the last question; Back returns to the last question. `data-save="key"` stores the answers and the shown question in `localStorage` as `questionnaire:key`, restores them on init when the questions are unchanged, and removes them on submit. A restored answer fires `change`, so a page that switches questions on from an answer sees it. File, password and hidden inputs are not saved or restored, so a secret never sits in storage and the server's hidden values stay as rendered. On a site where several people share a browser, put the account in the key.

```html
<form class="questionnaire" aria-label="Product survey">
  <fieldset>
    <legend>What should we prototype next?</legend>
    <p>Pick the direction you want to explore first.</p>
    <label><input type="radio" name="direction" value="search" required>Search<small>Find any page in two keystrokes</small></label>
    <label><input type="radio" name="direction" value="sharing" required>Sharing</label>
    <p class="questionnaire-error">Choose an answer to continue.</p>
  </fieldset>
  <fieldset data-required>
    <legend>Where do you use the app?</legend>
    <label><input type="checkbox" name="platforms" value="web">Web</label>
    <label><input type="checkbox" name="platforms" value="mobile">Mobile</label>
    <p class="questionnaire-error">Choose at least one platform.</p>
  </fieldset>
  <fieldset>
    <legend>Anything else we should know?</legend>
    <div class="field">
      <textarea id="notes" name="notes" class="expressive-textarea" placeholder=" "></textarea>
      <label for="notes">Notes</label>
    </div>
  </fieldset>
  <footer>
    <button type="button" class="text" value="previous">Back</button>
    <button type="button" class="text" value="skip">Skip</button>
    <button type="button" value="next">Next</button>
    <button type="submit">Submit</button>
  </footer>
</form>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `progressLabel` | String | `'Question {current} of {total}'` | Text of the progress row. `{current}` and `{total}` become the question number and count. `data-progress-label` on the form sets it. |
| `summaryLabel` | String | `'Review your answers'` | With `data-summary`, the review step's name and its progress text. |
| `editLabel` | String | `'Edit'` | With `data-summary`, the text of each answer's button back to its question. |
| `skippedLabel` | String | `'Not answered'` | With `data-summary`, the answer shown for a question left blank. |
| `requiredMessage` | String | `'Choose an answer to continue.'` | Error for a question with no `.questionnaire-error` when the browser has no message, as for a `data-required` group. |

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `items` | Array | The fieldsets that apply, in order. |
| `index` | Number | Position of the shown question in `items`. |

### Methods

#### .next()

Checks the shown question and moves to the next one if it is answered.

#### .previous()

Moves to the previous question.

#### .skip()

Clears the answer to the shown question and moves to the next one.

#### .review()

With `data-summary`, shows the review step.

#### .goTo(index)

Shows the question at `index` without checking the current one, for example to resume a saved questionnaire.

#### .destroy()

Shows every question and removes the progress row, ids, states and listeners the component added.

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-questionnaire-choice-shape` | 12px |
| `--md-comp-questionnaire-choice-outline-color` | `outline-variant` |
| `--md-comp-questionnaire-choice-selected-color` | `secondary-container` |

---

## Rating

Stars for choosing or showing a score, on native radio buttons.

For input, a `<fieldset class="rating">` holds a `<legend>` and one radio per star, each in a `<label>` whose visually hidden text ("3 stars") comes before the radio. The radio is drawn as the star, so Tab, the arrow keys, `required` and form submission stay native. A star fills when it or a later star is checked, and hovering previews a choice. Put the text before the radio: a span after it is the older `input + span` radio, which hides the input. CSS only; there is no JavaScript.

```html
<fieldset class="rating">
  <legend>Rate this recipe</legend>
  <label><span class="visually-hidden">1 star</span><input type="radio" name="score" value="1"></label>
  <label><span class="visually-hidden">2 stars</span><input type="radio" name="score" value="2"></label>
  <label><span class="visually-hidden">3 stars</span><input type="radio" name="score" value="3" checked></label>
  <label><span class="visually-hidden">4 stars</span><input type="radio" name="score" value="4"></label>
  <label><span class="visually-hidden">5 stars</span><input type="radio" name="score" value="5"></label>
</fieldset>
```

### Display

To show a score, give an element `class="rating"`, `role="img"`, a name with the value and `--md-comp-rating-value`. The stars fill to that value, halves included, from the start edge.

```html
<span class="rating" role="img" aria-label="Rated 4.5 out of 5" style="--md-comp-rating-value: 4.5"></span>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-rating-size` | 24px; display: 20px |
| `--md-comp-rating-color` | `tertiary` |
| `--md-comp-rating-empty-color` | `outline-variant` |
| `--md-comp-rating-value` | 0 (display) |
| `--md-comp-rating-max` | 5 (display) |

---

## Color swatches

Pick one color from a preset palette, on native radio buttons.

A `<fieldset class="color-swatches">` holds a `<legend>` and one radio per color, each in a `<label>` whose visually hidden text ("Purple") comes before the radio, with `--md-comp-color-swatch-color` set on the radio. The radio is drawn as a 40px round chip of that color with a 48px target, and the checked one gets a ring, so the choice is marked by shape as well as color. Tab, the arrow keys and form submission stay native. Add an `<input type="color">` in a label of its own at the end for a custom value; it is already drawn as a matching round swatch. Under forced colors the swatches keep their colors and the checked one gets a `Highlight` outline. CSS only; there is no JavaScript.

```html
<fieldset class="color-swatches">
  <legend>Label color</legend>
  <label><span class="visually-hidden">Purple</span><input type="radio" name="label-color" value="#6750a4" style="--md-comp-color-swatch-color: #6750a4" checked></label>
  <label><span class="visually-hidden">Green</span><input type="radio" name="label-color" value="#386a20" style="--md-comp-color-swatch-color: #386a20"></label>
  <label><span class="visually-hidden">Red</span><input type="radio" name="label-color" value="#b3261e" style="--md-comp-color-swatch-color: #b3261e"></label>
  <label><span class="visually-hidden">Custom color</span><input type="color" name="label-color-custom" value="#ff8800"></label>
</fieldset>
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-color-swatch-color` | transparent; set it on each radio |
| `--md-comp-color-swatches-size` | 40px |
| `--md-comp-color-swatches-outline-color` | `outline` |
| `--md-comp-color-swatches-selected-color` | `on-surface` |

---

## Color picker

Choose any color with a saturation and brightness area, a hue slider and a hex field, over a native color input.

Write a `.color-picker` with `role="group"` and a name around an `<input type="color">`. Without JavaScript that is the native swatch and the browser's picker. `ColorPicker`, which AutoInit starts, adds three controls around the input: a saturation and brightness area, a hue slider and a hex field. The native input stays the form value and the preview, and the browser's picker still opens from it. Every change writes the input and fires its `input` event, and a committed change (a released drag, a key press, a released hue, an applied hex value) also fires `change`, so existing listeners keep working.

The area is a `role="slider"` with `aria-valuetext` such as "Saturation 60%, brightness 80%". Left and Right change saturation, Up and Down brightness, Shift takes steps of 10%, Page Up and Page Down change brightness by 10%, and Home and End go to no and full saturation. The area and the hue track are the color itself, so they do not mirror in right-to-left pages and keep their colors under forced colors. The hue slider is the framework's range input. In the hex field, Enter applies the value instead of submitting the form, and an invalid value goes back to the current color. The browser's color input only holds `#rrggbb`, so there is no alpha.

```html
<div class="color-picker" role="group" aria-label="Accent color">
  <input type="color" name="accent" value="#6750a4" aria-label="Accent color">
</div>
```

```js
const picker = Expressive.ColorPicker.init(el, {
  onChange: (value) => console.log(value), // '#6750a4'
});
```

### Tokens

| Token | Default |
| --- | --- |
| `--md-comp-color-picker-width` | 280px |
| `--md-comp-color-picker-area-height` | 160px |
| `--md-comp-color-picker-area-shape` | `corner.medium` (12px) |
| `--md-comp-color-picker-thumb-size` | 24px |
| `--md-comp-color-picker-field-shape` | `corner.extra-small` (4px) |

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `onChange` | Function | `null` | Called with the new `#rrggbb` value when a change is committed. For live updates, listen for `input` on the native input. |

### Methods

#### .destroy()

Removes the area, the hue slider and the hex field. The native color input stays with its current value.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `input` | Element | The native color input. |
| `value` | String | The current `#rrggbb` color. Setting it updates the picker and the input without firing events, as setting a native input does. |

---

## Switches

Material Design 3 switches, from the HTML.

A `<label class="switch">` wrapping `<input type="checkbox">` is the control. The label text is a sibling of the input — no `.lever` required. They are CSS only. There is no JavaScript component and nothing to AutoInit.

Tokens follow the [M3 switch spec](https://m3.material.io/components/switch/specs). The track is 52×32dp. Unselected is `surface-container-highest` with a 2dp `outline` and a 16dp handle. Selected is a `primary` track and a 24dp `on-primary` handle. The state layer is 40dp at 8% hover and 10% focus. The touch target is 48dp. The label is `body-large` / `on-surface`. Disabled is 38%.

```html
<label class="switch">
  <input type="checkbox">
  Wi-Fi
</label>

<label class="switch">
  <input type="checkbox" checked>
  Bluetooth
</label>

<label class="switch">
  <input type="checkbox" disabled>
  Airplane mode
</label>
```

The on/off captions are decoration. Left as bare text they are folded into the label, and the switch announces itself as "Off On" — so hide them and name the control itself. A switch whose only text is a caption needs an `aria-label`, because hiding both leaves it with no name at all.

```html
<label class="switch">
  <span aria-hidden="true">Off</span>
  <input type="checkbox" aria-label="Dark mode">
  <span aria-hidden="true">On</span>
</label>
```

---

## Select

Choose one option, or several, from a styled menu.

Filled enhanced selects reserve a label row that grows with wrapping and enlarged text. Leading icons retain their label spacing; supporting text stays outside the filled surface. Native and outlined variants retain their existing layouts.

Select turns a native `<select>` into a menu. Wrap it in a `.field` and give the label a matching `for`. `AutoInit()` starts every `select` except those marked `no-autoinit`. Add `browser-default` to keep the native control.

Add `multiple` to select several options. Chosen values appear as a comma-separated list.
Distinct options with the same value can be selected and deselected independently.

Native `<optgroup>` elements become group headings in the menu.

Put an image URL in `data-icon` on an option. Classes on that option are copied to the image; `left` floats it left. Images float right by default. There is no `icons` class on the `<select>`.

Icon URLs may be relative, HTTP(S), blob URLs, or image data URLs (AVIF, BMP, GIF, JPEG, PNG, SVG, WebP, or ICO). Invalid URLs and other schemes are ignored; the option remains selectable.

Add `browser-default` to skip the menu and keep the native select.

```html
<div class="field">
  <select id="form-select-1">
    <option value="" disabled selected>Choose your option</option>
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
    <option value="3">Option 3</option>
  </select>
  <label for="form-select-1">Expressive Select</label>
</div>

<div class="field">
  <select id="form-select-2" multiple>
    <option value="" disabled selected>Choose your option</option>
    <option value="1">Option 1</option>
    <option value="2">Option 2</option>
    <option value="3">Option 3</option>
  </select>
  <label for="form-select-2">Multiple Select</label>
</div>

<div class="field">
  <select id="form-select-3">
    <optgroup label="team 1">
      <option value="1">Option 1</option>
      <option value="2">Option 2</option>
    </optgroup>
    <optgroup label="team 2">
      <option value="3">Option 3</option>
      <option value="4">Option 4</option>
    </optgroup>
  </select>
  <label for="form-select-3">Optgroups</label>
</div>

<div class="field">
  <select id="form-select-4">
    <option value="" disabled selected>Choose your option</option>
    <option value="1" data-icon="photo.jpg">example 1</option>
    <option value="2" data-icon="photo.jpg" class="left">example 2</option>
  </select>
  <label for="form-select-4">Images in select</label>
</div>

<label for="form-select-6">Browser Select</label>
<select id="form-select-6" class="browser-default">
  <option value="" disabled selected>Choose your option</option>
  <option value="1">Option 1</option>
</select>
```

### Select search

Set `searchable: true` on FormSelect or use `data-form-select-options='{"searchable":true}'` on a plain single or multiple select. The read-only trigger continues to show the chosen option. Its popup is a named nonmodal `dialog` containing a labelled search field and a separately named `listbox`; the trigger has `aria-haspopup="dialog"`. Search and the polite no-results status are outside the listbox.

```html
<select name="destination" aria-label="Destination" data-form-select-options='{"searchable":true}'>
  <optgroup label="Europe">
    <option value="paris">Paris</option>
    <option value="rome">Rome</option>
  </optgroup>
  <optgroup label="Asia"><option value="tokyo">Tokyo</option></optgroup>
</select>
```

```js
Expressive.FormSelect.init(select, {
  searchable: true,
  i18n: { searchLabel: 'Find a destination', noResults: 'No destinations match' }
});
```

Search matches a literal case-insensitive substring of trimmed option text. It trims query edges, preserves internal whitespace and does not search values, group labels or icon URLs, interpret regular expressions, or fold accents. Empty groups disappear. Matching disabled options remain visible and unavailable. Hidden and disabled results cannot receive selection or navigation focus. A disabled-only match is still a result; `No results` means zero matches.

Typing and clearing never change native selection, even when the chosen option is hidden. Only selecting a visible enabled result updates the native select; value changes fire the existing native `change` event once. Distinct options with the same value retain their identity and existing value-based event behavior. Search has no `name`, so form submission contains the selected option's value. Enter in search does not submit or implicitly choose a result.

| Key or action | Searchable select behavior |
| --- | --- |
| Open by pointer, Enter, Space or a vertical arrow | Focus the empty search field with the default `menuOptions.autoFocus`. |
| Type, paste, Space, Home, End, horizontal arrows or composition in search | Edit text normally without Menu typeahead. Enter does not select or submit. |
| ArrowDown / ArrowUp in search | Focus the first / last visible enabled result, or stay in search if none exists. |
| ArrowDown / ArrowUp on results | Move without wrapping, skipping hidden/disabled options and headings. Up from the first result returns to search. |
| Home / End on results | Focus the first / last visible enabled result. |
| Enter / Space on a result | Single select: choose it, close and focus the trigger. Multiple select: toggle it and keep the popup open, the query and focus on the row. |
| Tab / Shift+Tab | Tab from search focuses the first enabled result; Tab from a result exits to the next control. Without enabled results, Tab exits from search. Shift+Tab from a result returns to search; from search it closes and focuses the trigger. |
| Escape / outside click | Close without changing selection. Escape focuses the trigger; outside dismissal keeps its target's focus. |

Every close clears the query and restores the unfiltered list. A successful native reset closes search and restores defaults; a canceled reset preserves state. `refresh()` reapplies an open query, preserves search focus/caret or a surviving enabled result, and returns focus to search when that result disappears. Disabling the control or its fieldset and refreshing closes it. `menuOptions.autoFocus: false` leaves existing focus alone on open; Tab from the trigger can still reach search. Existing open/close callbacks remain supported.

On a multiple select, the inner listbox has `aria-multiselectable="true"`. Pointer, Enter or Space toggles a result, fires one `change` when the selection changes, and leaves the popup open with the query unchanged and focus on that row, so several matches can be picked in a row. An option hidden by the query stays selected, stays in the trigger text and is submitted. Escape, Tab or an outside click closes the popup, clears the query and keeps every selection.

```html
<select name="places" multiple aria-label="Places" data-form-select-options='{"searchable":true}'>
  <option value="paris">Paris</option>
  <option value="rome">Rome</option>
  <option value="tokyo">Tokyo</option>
</select>
```

Search defaults to off and is ignored for tree and `browser-default` selects. Tree filtering is a separate roadmap phase. Multiple search has no select-all for matches or summary of hidden selections. This mode has no remote search, option creation, fuzzy matching, highlighting, virtualization or mutation observer.

### Initialization

The IIFE bundle exposes `Expressive.FormSelect`. Call `init` yourself when you need options other than the defaults, after adding a select dynamically, or after changing an existing select’s options. Otherwise `Expressive.AutoInit()` starts every `select`.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('select');
  const instances = Expressive.FormSelect.init(elems, {
    // specify options here
  });
});
```

Per-instance options can also be passed through AutoInit:

```js
Expressive.AutoInit(document.body, {
  FormSelect: { menuOptions: { constrainWidth: false } }
});
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `classes` | String | `''` | Space-separated classes added to the `.field` wrapper, either the parent `.field` or one the plugin creates. |
| `menuOptions` | Object | `{}` | Options passed to `Menu`. See Menu. `coverTrigger` is forced to `false` and `closeOnClick` is forced to `false`. |
| `searchable` | Boolean | `false` | Add a popup search field to a plain single/multiple FormSelect or single/multiple TreeSelect. Browser-default selects stay native. |
| `i18n` | Object | `{ searchLabel: 'Search options', noResults: 'No results' }` | Search input label and no-results status. Override either string; omitted keys retain defaults. |

There is no `selected` option. Mark options with the HTML `selected` attribute instead.

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.FormSelect.getInstance(elem);
```

#### .getSelectedValues();

Selected values as an array of strings.

```text
instance.getSelectedValues();
```

#### .refresh();

Re-read native values, option labels, options and disabled states. The generated rows are rebuilt while the field and an existing Menu instance remain. Refresh preserves focus on a surviving option in an open menu; it does not emit a user `change` event. Call it after programmatic changes, including moving the select to another form or changing its `form` attribute, to rebind reset handling. There is no mutation observer.

The native select owns form submission. Disabled options and optgroups cannot be activated, even when selected programmatically. Their selected labels remain visible, but native `FormData` excludes disabled choices. An empty selection displays an empty field. Successful native form resets synchronize the field and selection semantics after the browser restores defaults. Canceled resets preserve selection; resets do not synthesize `change`.

The enhanced field exposes one combobox to assistive technology. All associated labels contribute to its name in their original order, unless an authored `aria-label` or `aria-labelledby` supplies the name. Each label targets the visible control. The native select stays visually hidden and excluded from the accessibility tree while initialized. Required and custom validation errors use its browser-provided message, displayed as supporting text associated with the visible field. Native validation chooses which field receives focus, and the enhanced select forwards that focus to its visible field. `checkValidity()` preserves focus; `reportValidity()` on a select reports that control even when an earlier field is invalid. After changing `setCustomValidity()` from script, call `refresh()` to update an existing error. Correcting the value clears resolved errors; resetting the form clears displayed errors without changing native validation rules. `destroy()` removes generated error messages and restores the native control.

#### .destroy();

Destroy the plugin instance, remove the menu, and restore the native select. Associated labels keep their original nodes and event listeners; teardown restores their attributes and authored positions.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The native `<select>` the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `isMultiple` | Boolean | Whether this is a multiple select. |
| `wrapper` | Element | The `.field` wrapper, either the parent `.field` or one the plugin creates. |
| `menuEl` | Element | The generated popup, a `<menu>` by default or a dialog `<div>` containing a listbox in searchable mode. |
| `labelEl` | Element | The associated label, or `null` if none was found. |
| `input` | Element | The text input that shows the current selection. |
| `menu` | Menu | The Menu instance for this select. |

### Disabled styles

`disabled` on the `<select>` disables the whole control. `disabled` on an `<option>` makes that item unselectable.

```html
<div class="field">
  <select id="form-select-7" disabled>
    <option value="" disabled selected>Choose your option</option>
    <option value="1">Option 1</option>
  </select>
  <label for="form-select-7">Disabled Select</label>
</div>

<label for="form-select-8">Browser Disabled</label>
<select id="form-select-8" class="browser-default" disabled>
  <option value="" disabled selected>Choose your option</option>
  <option value="1">Option 1</option>
</select>
```

---

## Tree select

Choose leaves from options nested as a tree, expanded with the keyboard or pointer.

A native `<select data-tree>` holds flat `<option>`s. A child names its parent option's value in `data-parent`; an option without one is a root, and an option another option names is a branch. `AutoInit()` starts every `select[data-tree]` as `TreeSelect`, a FormSelect subclass, so the field, labels, validation messages and reset work as in a select. The native select still owns the form value, reset, validity and the disabled state. HTML cannot nest `<optgroup>`s, so nesting comes only from `data-parent`.

The popup is a `<ul role="tree">` inside the Menu surface, named as the field is: its `aria-labelledby`, then its `aria-label`, then its labels. Branch rows have `aria-expanded` and hold their children in a `ul[role="group"]`; leaf rows have `aria-selected`. Branches start collapsed. In default mode, opening reveals and focuses the first chosen enabled leaf, or the first enabled row; search mode focuses its separate search field.

Only a leaf can be chosen. While the component runs, it sets `disabled` on every branch option, so the form never submits a branch; `destroy()` and `refresh()` restore that attribute. In single mode, if the browser picked a branch only because nothing was `selected`, the first enabled leaf is chosen instead, as a form reset would pick. In single mode, a branch the markup or a script selects shows in the field and submits nothing. Multiple mode clears selected branches before trigger synchronization and never selects replacement leaves; authored leaf choices and defaultSelected remain native-owned. Put `disabled` on branch options in markup so the native page cannot submit a branch. In single mode, also start with a `value=""` placeholder. Multiple selects need no selected placeholder.

| Key | Action |
| --- | --- |
| Down, Up | Next or previous visible row, without wrapping. Disabled leaves are skipped. |
| Right | Opens a closed branch, or moves to an open branch's first child. |
| Left | Closes an open branch, or moves to the parent row. |
| Home, End | First or last visible row. |
| `*` | Opens every branch beside the focused row. |
| Enter, Space | Chooses a leaf, or opens or closes a branch. |
| A character | Moves to the next visible row whose text starts with the characters typed in the last second. |
| Escape, Tab / Shift+Tab | Close back to the trigger without search. The following Tab moves normally. Search has its own traversal below. |

Left and Right swap under `dir="rtl"`. A click on a branch opens or closes it and keeps the popup open; a click on a leaf chooses it and fires one native `change` event when the value changed. Single mode closes; multiple mode toggles only that leaf and keeps the popup open.

```html
<div class="field">
  <select id="place" name="place" data-tree>
    <option value="" disabled selected>Choose a place</option>
    <option value="europe" disabled>Europe</option>
    <option value="france" data-parent="europe" disabled>France</option>
    <option value="paris" data-parent="france">Paris</option>
    <option value="lyon" data-parent="france">Lyon</option>
    <option value="spain" data-parent="europe">Spain</option>
    <option value="asia" disabled>Asia</option>
    <option value="tokyo" data-parent="asia">Tokyo</option>
  </select>
  <label for="place">Place</label>
</div>
```

There is no choosable branch, `<optgroup>`, `data-icon` image or children loaded on demand. The field shows the leaf's text, not its path. To disable a subtree, disable each leaf.

TreeSelect takes FormSelect's `classes`, `menuOptions`, `searchable` and search `i18n` through script. Markup `data-tree-select-options` accepts classes, searchable and existing i18n keys; pass nested Menu options such as autoFocus through script.

### Multiple leaves

Add native `multiple` to opt in. Click, Enter or Space toggles one enabled leaf without modifiers. Branches never select descendants. Focus movement leaves choices unchanged, and the popup stays open. The named actual tree has aria-multiselectable true; leaves expose aria-selected, branches only expansion/group state. No checkboxes are generated.

```html
<label for="many-places">Places</label>
<select id="many-places" name="places" multiple data-tree data-tree-select-options='{"searchable":true}'>
  <option value="europe" disabled>Europe</option>
  <option value="paris" data-parent="europe" selected>Paris</option>
  <option value="lyon" data-parent="europe">Lyon</option>
  <option value="asia" disabled>Asia</option>
  <option value="tokyo" data-parent="asia" selected>Tokyo</option>
</select>
```

The trigger and getSelectedValues() retain native option order. FormData.getAll('places') submits both chosen leaves, including choices hidden by search, and no branch. Empty or branch-only defaults stay empty; required remains invalid. Each real toggle emits one native change; query edits, branch navigation, refresh and reset emit none. Reset restores authored leaf defaults without branch propagation; canceled reset preserves the live state. Destroy/reinitialize after changing multiple. Browser-default controls remain native.

### Tree search

Set `searchable: true` with `TreeSelect.init` or `data-tree-select-options` on a single or multiple tree. The read-only trigger keeps the chosen leaf's text and controls a named nonmodal dialog with `aria-haspopup="dialog"`. A separately labelled search input, polite no-results status and the named `role="tree"` share that popup. Search and status remain outside the tree, with no listbox wrapper. Names honor the field's authored names or labels in its document or shadow root.

```html
<label for="search-place">Search places</label>
<select id="search-place" name="place" data-tree data-tree-select-options='{"searchable":true}'>
  <option value="" disabled selected>Choose a place</option>
  <option value="europe" disabled>Europe</option>
  <option value="france" data-parent="europe" disabled>France</option>
  <option value="paris" data-parent="france">Paris</option>
  <option value="lyon" data-parent="france">Lyon</option>
  <option value="asia" disabled>Asia</option>
  <option value="tokyo" data-parent="asia">Tokyo</option>
</select>
```

```js
Expressive.TreeSelect.init(select, {
  searchable: true,
  i18n: { searchLabel: 'Find a leaf', noResults: 'No matching leaves' }
});
```

Search trims query edges and matches a literal case-insensitive substring of each leaf's trimmed text. It preserves internal spaces and accents. Values and branch labels are excluded, and regular-expression syntax stays literal. Matching leaves and their ancestor paths stay visible, and their branches open. Disabled matches remain visible and unavailable; a disabled-only match is still a result. No matching leaves produces the localized status as safe text. Native options are never removed or rewritten by filtering.

The first nonempty query saves open branch values. Further edits, temporary branch toggles and refresh retain this baseline. Clearing or closing shows every row and restores surviving branches to it; newly added branches are closed on restoration. Query edits and refresh reveal matching paths again. Typing, clearing, hiding a selected leaf and dismissal leave its native value and trigger text intact and emit no change. Search has no name and is never submitted. Only a visible enabled leaf pick changes native selection and emits one change. Single mode closes; multiple mode keeps the query and row focus for the next toggle. Branches still contribute no form value.

| Key or action | Searchable tree behavior |
| --- | --- |
| Open | Focus search by default. Delayed opening preserves focus already inside the popup. autoFocus false leaves current focus alone; Tab from the trigger can reach search. |
| Text editing in search | Typing, paste, Space, Home/End, horizontal keys and composition edit normally. Enter neither submits nor chooses. |
| ArrowDown / ArrowUp in search | Focus the first / last enabled visible tree row, or remain in search if none exists. |
| Keys on a tree row | Keep the tree arrows, Home/End, star, typeahead and branch/leaf activation above. Hidden rows and disabled leaves are excluded. Up from the first row stays there. RTL still mirrors branch arrows. |
| Tab / Shift+Tab | Tab enters the first enabled row from search. Tab from a row, or search with no enabled row, closes and advances after the trigger. Shift+Tab returns from a row to search, or closes from search to the trigger. |
| Escape / outside dismissal | Close, clear the query and restore expansion. Escape focuses the trigger; outside dismissal preserves its target's focus. |

Refresh reapplies the query and preserves the original expansion baseline and search focus/caret. A surviving enabled visible row keeps focus; otherwise focus moves to an enabled visible row or search. An uncanceled native reset closes, clears and restores default selection and expansion; cancellation retains query, selection and expansion. Disabling the control or its fieldset and refreshing closes it. Existing Menu callbacks are preserved. Destroy removes search controls, listeners and timers and restores native branch attributes.

Search defaults off, stays off for browser-default controls, and adds no remote search, lazy children, fuzzy matching, accent folding, highlighting, virtualization or new selection event.

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `tree` | HTMLUListElement | The `role="tree"` list inside the popup. |

### Methods

#### .refresh()

Rebuilds the tree from the native options after a page adds, removes or changes them. Without search, branches whose value is still there stay open. An active query is reapplied without overwriting its saved expansion baseline. While open, focus stays on a surviving enabled visible row, moves to an enabled visible row, or returns to search when none remains.

#### .getSelectedValues()

The selected native option values in option order, as in FormSelect.

#### .destroy()

Removes the tree and its listeners, restores the branch options' `disabled` attributes and the native select.

---

## Cascader

Pick a path through nested options shown in columns, where each choice opens the next column, and submit the whole path.

Write a `.field.cascader` holding a text `<input>` with a `name`, its `<label for>`, and a `ul.cascader-options` of nested lists. Each `<li>` holds a `data-value`, and its label is its own text outside any nested list. An `<li>` with a nested list of options is a branch; any other is a leaf. `AutoInit()` starts every `.cascader`. The lists stay in the page, hidden, and the script builds the columns from them; leave roles off them.

The input becomes read-only, with `role="combobox"` and `aria-haspopup="dialog"`. A click, or Enter, Space, ArrowDown or Alt+ArrowDown, opens a popover below the field with `role="dialog"`. Each column is a listbox named by the field's label or by the option that opened it, and the options on the open path are `aria-selected="true"`. A branch ends in a chevron.

| Key | In a column |
| --- | --- |
| ArrowDown, ArrowUp | Next or previous option, without wrapping |
| Home, End | First or last option |
| ArrowRight | On a branch, open the next column and move into it |
| ArrowLeft | Back to the option that opened this column; the columns stay |
| Enter, Space | On a branch, as ArrowRight; on a leaf, commit the path |
| Escape, Tab, Shift+Tab | Close without committing, and return focus to the input |

Under `dir="rtl"` the columns run right to left and ArrowLeft and ArrowRight swap. A click on a branch opens its column and keeps focus on the branch. A click on a leaf commits. A click outside closes without committing.

Committing shows the path's labels in the input, such as `United States / California / San Francisco`, closes the popover, returns focus to the input and fires `input` and `change` on it. Committing the same path again fires nothing. The form gets one entry per level under the input's name, in order, through the form-associated `expressive-cascader-control` that wraps the input: `place=us&place=ca&place=sf`. With no path the form gets no entry.

Mark a leaf `data-selected` to start with its path; the first marked leaf wins, and a marked branch is ignored. A form reset brings the starting path back without events. With `required` on the input, the field fails validation until a path is committed, with the message `Choose an option.`, and a failed submit or `checkValidity()` sets `aria-invalid="true"` on the input. A disabled input or fieldset submits nothing and does not open, and a popover open when the field is disabled closes on the next choice without committing. Options added after start need `destroy()` and a new `init`.

```html
<div class="field cascader">
  <input id="place" name="place" type="text" required>
  <label for="place">Place</label>
  <ul class="cascader-options">
    <li data-value="us">United States
      <ul>
        <li data-value="ca">California
          <ul>
            <li data-value="sf">San Francisco</li>
            <li data-value="la">Los Angeles</li>
          </ul>
        </li>
        <li data-value="wa">Washington</li>
      </ul>
    </li>
    <li data-value="cn">Canada</li>
  </ul>
</div>
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `i18n` | Object | `{ required: 'Choose an option.' }` | `required` is the validity message while a required field has no path. Markup sets it with `data-cascader-options='{"i18n":{"required":"Elige un lugar."}}'` on the `.cascader`. |

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `value` | Array | The committed path's values, such as `['us', 'ca', 'sf']`, or an empty array. |
| `input` | Element | The text input that shows the path. |
| `popover` | Element | The popover of columns. |
| `isOpen` | Boolean | Whether the popover is open. |

### Methods

#### .open()

Opens the popover at the committed path, with focus on its leaf or on the first option. A disabled field does not open.

#### .close()

Closes the popover and keeps the committed path.

#### .destroy()

Closes and removes the popover, unwraps the input, and gives back its name, `required` and attributes. The input keeps its text.

---

## Sliders

Sliders handle a maximum of zero and update value-label placement when the input resizes, including horizontal RTL placement.

Material Design 3 sliders, from the HTML.

An `<input type="range">` is the control. A wrapping `.slider` (or a `<label>`) is the host for the value label.

Labels and track gaps align with native handle travel after resizing in LTR, RTL and vertical layouts. Vertical values increase from bottom to top. Paired inputs accept native track clicks and prevent crossing; use matching bounds and steps. Application-owned numeric fields can assign the range value and dispatch an `input` event to synchronize its label and track. Forced-colors tracks use system colors. Physical devices, screen readers and actual Windows contrast themes still need manual review.

The plugin is `Expressive.Slider`. Before 0.8.0 `.slider` and `Slider` meant the image slideshow; that component is now gone and Carousel covers the case, so `.slider` is the range control and nothing else.

Three variants: **standard** (active from the start to the handle), **centered** (`.centered`, active grows from the midpoint), and **range** (two inputs in one host, active between the handles). Horizontal or `.vertical`. Five sizes, an optional inset icon, discrete stops, and a value indicator.

Tokens follow the [M3 slider spec](https://m3.material.io/components/sliders/specs) (Expressive). The handle is a 4dp stop with a 6dp gap to each track, narrowing to 2dp while pressed. Active is `primary`; inactive is `secondary-container`. The end of an inactive track carries a 4dp stop. The value label is a 40dp `inverse-surface` / `inverse-on-surface` bubble, `body-small`. Disabled is 38%.

Slider is not in `AutoInit()`. Importing the IIFE bundle calls `Expressive.Slider.Init()`, which starts every `input[type=range]` already in the document and keeps the active track in sync. `no-autoinit` does not apply.

```html
<label>
  Volume
  <input type="range" min="0" max="100" value="40">
</label>
```

`min`, `max`, `step`, and `value` are the native attributes. When they are omitted, the control treats the range as 0–100. Dragging shows the current value above the handle.

### Variants

`centered` grows the active track from 50%. A range slider is two inputs in one `.slider` host; the plugin keeps the start handle from passing the end one.

```html
<div class="slider centered">
  <input type="range" min="0" max="100" value="30" aria-label="Centered">
</div>

<div class="slider">
  <input type="range" min="0" max="100" value="25" aria-label="Range start">
  <input type="range" min="0" max="100" value="75" aria-label="Range end">
</div>
```

### Sizes

XS is the default: a 16dp track and a 44dp handle. `s` / `small` is S, `m` / `medium` is M, `l` / `large` is L, and `xl` is XL. Scope them on the host — unscoped `.small` / `.large` are used by other components.

```html
<div class="slider s">…</div>
<div class="slider m">…</div>
<div class="slider l">…</div>
<div class="slider xl">…</div>
```

### Inset icon, stops, value

A leading icon sits inside the active track; M, L, and XL are tall enough for it. `stops` plus a numeric `step` paints a dot on the active track at each step from `min`, except under the icon. Without a `step`, or with `step="any"`, the track has no dots.

```html
<div class="slider m">
  <span class="material-symbols" aria-hidden="true">volume_up</span>
  <input type="range" min="0" max="100" value="55" aria-label="Volume">
</div>

<div class="slider stops">
  <input type="range" min="0" max="100" step="20" value="40" aria-label="Stops">
</div>
```

### Vertical

`vertical` stands the track up. Minimum is at the bottom. The value label sits to the end of the handle.

```html
<div class="slider vertical m">
  <input type="range" min="0" max="100" value="60" aria-label="Level">
</div>
```


### Disabled

The native `disabled` attribute drops the control to 38%.

```html
<label>
  Locked
  <input type="range" min="0" max="100" value="30" disabled>
</label>
```

### Initialization

Put the script after the inputs (as this site does), or call `init` yourself. There are no options. For a range added after load:

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('input[type=range]');
  Expressive.Slider.init(elems);
});

Expressive.Slider.init(document.querySelector('#volume'));
```

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Slider.getInstance(elem);
```

#### .destroy();

Destroy the plugin instance, remove the value label, tear down its event handlers, and restore the inline custom properties it changed.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The range input the plugin was initialized with. |
| `options` | Object | Empty. Range has no options. |
| `thumb` | Element | The generated value label next to the input. |
| `value` | Element | The span inside the label that shows the current number. |

---

## Radio Buttons

Material Design 3 radios, from the HTML.

A `<label>` wrapping `<input type="radio">` is the control. The label text is a sibling of the input — no extra class, no required `<span>`. They are CSS only. There is no JavaScript component and nothing to AutoInit.

A radio only means anything as one of a set, so the set is a `<fieldset>` and the `<legend>` names the question the radios answer. Without it the options are announced one by one with nothing saying what is being chosen.

Tokens follow the [M3 radio spec](https://m3.material.io/components/radio-button/specs). The icon is a 20dp ring with a 2dp stroke. Selected is `primary` with a 10dp inner disc (the M3 gap). The state layer is 40dp at 8% hover and 10% focus. The touch target is 48dp. The label is `body-large` / `on-surface`. Disabled is `on-surface` at 38%.

Use the same `name` on every radio in a group. Add `disabled` to disable one. `with-gap` is a no-op — the selected state is always the ring plus inner disc.

```html
<fieldset>
  <legend>Colour</legend>
  <label>
    <input name="group1" type="radio" checked>
    Red
  </label>
  <label>
    <input name="group1" type="radio">
    Yellow
  </label>
  <label>
    <input name="group1" type="radio" disabled>
    Brown
  </label>
</fieldset>
```

### In a row

Put the labels in a `<div class="inline">` to sit them on one line. A bare group stacks vertically. It is not a `<nav>`: a row of form controls is not navigation, and marking it as one spends a landmark and announces "navigation" over a set of choices.

```html
<fieldset>
  <legend>Colour</legend>
  <div class="inline">
    <label>
      <input name="group2" type="radio" checked>
      Red
    </label>
    <label>
      <input name="group2" type="radio">
      Yellow
    </label>
  </div>
</fieldset>
```

---

## Chips

Small blocks for contacts, tags, and filters.

A chip is a `.chip`, and **the element says which kind it is** — the four Material 3 chip types, plus a non-interactive display chip, across three root elements. Add `outlined` for a bordered style. Static chips are CSS. The JavaScript plugin lives on a `.chips` wrapper.

`--md-comp-chip-padding`, `--md-comp-chip-label-size`, and `--md-comp-chip-icon-size` control horizontal padding, label font size, and direct child Material Symbol font size. Each takes priority over its old counterpart, respectively `--padding`, `--font-size`, and `--font-size-icon`. Old overrides on a chip remain supported as fallbacks, including fixed px values. The old names are deprecated with no removal date. These properties leave block padding, avatars, close buttons, the nested close glyph, and editable input sizes unchanged.

The label defaults to `--md-sys-typescale-label-large-font-size`. The chip minimum height, direct child icon font size, avatar and close-button boxes, and editable input font size, height and width limits use rem. At a 16px root the default label is 14px, icon is 18px and minimum height is 32px; at a 32px root they are 28px, 36px and 64px. The avatar and close-button boxes grow from 24px to 48px. Padding, gaps and borders stay in px. The nested close glyph keeps the shared button icon size. A long label wraps and the chip grows from its minimum height, with 4px above and below the lines. It never grows wider than its parent.

| Type | Element | Why |
| --- | --- | --- |
| Display | `<span class="chip">` | Presents information. Not a control, not in the tab order. |
| Assist, suggestion | `<button type="button" class="chip">` | One action on press. |
| Filter | `<input type="checkbox" class="chip-input">` + `<label class="chip">` | Multi-select and toggleable, and it carries a value into the form. No JavaScript. |
| Input | `<span class="chip">` + a nested `<button class="close">` | The chip is a token; the delete button is the control. |

```html
<span class="chip outlined">Information</span>

<span class="chip">
  <img src="photo.jpg" alt=""> Jane Doe
</span>

<button type="button" class="chip">
  <span class="material-symbols" aria-hidden="true">event</span>
  Add to calendar
</button>

<input type="checkbox" class="chip-input" id="filter-flights">
<label class="chip" for="filter-flights">
  <span class="material-symbols" aria-hidden="true">check</span>
  Flights
</label>

<span class="chip">
  Tag
  <button type="button" class="close" aria-label="Remove Tag">
    <span class="material-symbols" aria-hidden="true">close</span>
  </button>
</span>
```

| Chip property | Controls | Deprecated fallback | Default |
| --- | --- | --- | --- |
| `--md-comp-chip-padding` | Left and right padding | `--padding` | `8px` |
| `--md-comp-chip-label-size` | Label font size | `--font-size` | Label-large font-size token |
| `--md-comp-chip-icon-size` | Direct child Material Symbol font size | `--font-size-icon` | `1.125rem` |

```html
<span class="chip" style="--md-comp-chip-padding: 12px; --md-comp-chip-label-size: 1rem; --md-comp-chip-icon-size: 1.25rem;">
  <span class="material-symbols" aria-hidden="true">info</span>
  Custom sizing
</span>
```

The delete button needs its own `aria-label` naming the chip it removes, because its only content is an icon. The icon is `aria-hidden` in every chip: the ligature is real text and is otherwise read out alongside the label.

A filter chip's selected state is `:checked` on its input, so it needs no script. Everywhere else the selected look is the `selected` class.

Clicking `.close` removes the chip only when it sits inside a `.chips` container. Importing the bundle runs `Chips.Init()` on `DOMContentLoaded`, which wires that click. A lone `.chip` does not remove itself.

Before 0.8.0 every chip was a `<div class="chip">` and the delete affordance was an `<i class="close">` — focusable via `tabindex` but with no role and no name. In 0.16.0 that `<i>` is no longer an icon, so the button reads the word "close"; write the `<button class="close">` shown here.

### Contacts

Put an image inside the chip. The name next to it is the accessible name already, so the image is decorative — `alt=""`.

```html
<span class="chip">
  <img src="photo.jpg" alt="">
  Jane Doe
</span>
```

### Tags

Put a `button.close` inside the chip. Give it `type="button"` so it cannot submit a surrounding form, and an `aria-label` naming what it removes.

```html
<span class="chip">
  Tag
  <button type="button" class="close" aria-label="Remove Tag">
    <span class="material-symbols" aria-hidden="true">close</span>
  </button>
</span>
```

### JavaScript plugin

The plugin turns a `.chips` container into an editable tag field. Type a value and press Enter to add a chip. Delete with the chip's delete button, or select a chip and press Backspace or Delete. Selecting a chip marks it `selected` and moves focus to its delete button.

Backspace and Delete target the focused chip, then select the preceding survivor, otherwise the first survivor or the input. Delete-button activation returns to the input. Programmatic deletion recovers focus to the input only when it removes the focused chip. Deletion callbacks retain any focus they move. Long labels wrap without shrinking delete controls.

`allowUserInput` defaults to `false`. Without it there is no text field and rendered chips have no delete button. Pass `allowUserInput: true` for the interactive field. `AutoInit()` starts every `.chips` except `no-autoinit`, but it uses the defaults, so those wrappers stay display-only until you call `init` with options.

In a form, give the field's `<input>` a `name`. The chips submit under it, one `id` per chip, as a multiple select does. `init` wraps the input in an `<expressive-chips-control>`, a form-associated custom element with `display: contents`, and moves the name onto it, so half-typed text is not submitted. The form treats the control as the field. A disabled `fieldset` or a form reset applies to it at once, including in a detached form or a shadow root, and a canceled reset changes nothing. Setting `disabled`, `readonly` or `form` on the input applies at the next microtask. A `required` input makes the field valid once it has a chip, and says `i18n.required` until then, unless the input is read-only. The control holds that validity apart from any error the page sets on the input, and a blocked submit focuses the input. The input itself stays valid and does not match `:invalid`, so style or report the error from the control or the form. The input keeps `aria-required="true"`. `destroy()` unwraps the input and gives its name and `required` back. Without form-associated custom elements, as in jsdom, the input is left as it is and submits its own text. Pasting text that lists values, split by commas, tabs or new lines, adds a chip for each, up to `limit`; an `autocompleteOnly` field pastes as text. With `allowCreate: true` in `autocompleteOptions`, choosing the `Add` row adds the typed text as a chip, in an `autocompleteOnly` field too, and then calls the page's own `onCreate`.

```html
<form>
  <div class="chips">
    <input name="tags">
  </div>
</form>
<!-- Submits tags=red&tags=blue -->
```

Empty field — type a tag and press Enter:

Initial tags from the `data` option:

Placeholders when the field is empty and after the first tag:

Autocomplete suggestions while typing:

```html
<div class="chips"></div>
<div class="chips chips-initial"></div>
<div class="chips chips-placeholder"></div>
<div class="chips chips-autocomplete"></div>
<!-- Optional: provide your own input -->
<div class="chips">
  <input class="custom-class">
</div>
```

The classes `chips-initial`, `chips-placeholder`, and `chips-autocomplete` are only hooks for your selectors. They have no styles of their own.

### Initialization

The IIFE bundle exposes `Expressive.Chips`. Call `init` with `allowUserInput: true` (and any other options) for an editable field. Re-init after adding a container dynamically. Autocomplete keeps the query while filtering and clears it after an item is selected.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.chips');
  const instances = Expressive.Chips.init(elems, {
    allowUserInput: true,
    placeholder: 'Enter a tag',
    secondaryPlaceholder: '+Tag',
    autocompleteOptions: {
      data: [
        { id: 12, text: 'Apple' },
        { id: 13, text: 'Microsoft' },
        { id: 42, text: 'Google', image: 'https://picsum.photos/id/64/250/250' }
      ]
    }
  });
});
```

Chip data object. `id` is required; a chip without an id is not rendered.

```js
const chip = {
  id: '4711',
  text: 'Title',
  image: ''
};
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | Array | `[]` | Initial chips. Each item is a chip data object. |
| `placeholder` | String | `''` | Placeholder when there are no chips. Requires `allowUserInput`. |
| `secondaryPlaceholder` | String | `''` | Placeholder after at least one chip exists. |
| `closeIconClass` | String | `'material-symbols'` | Class on the icon inside the delete button. |
| `allowUserInput` | Boolean | `false` | If true, render a text field and a delete button per chip, so the user can add and remove chips. |
| `i18n` | Object | `{ remove: 'Remove', required: 'Add at least one item.' }` | Strings the component generates. `remove` prefixes the delete button's accessible name, giving "Remove Apple". `required` is what a required field says while it has no chips. |
| `autocompleteOptions` | Object | `{}` | Options passed to Autocomplete on the input. A non-empty object enables autocomplete. |
| `autocompleteOnly` | Boolean | `false` | If true, Enter will not add a value that is not in the autocomplete list. An `Add` row from `allowCreate` still adds its text. |
| `limit` | Number | `Infinity` | Maximum number of chips. |
| `onChipAdd` | Function | `null` | Called after a chip is added. Receives the container and the chip element. |
| `onChipSelect` | Function | `null` | Called when a chip is selected. Receives the container and the chip element. |
| `onChipDelete` | Function | `null` | Called after a chip is deleted. Receives the container and the chip element. |

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Chips.getInstance(elem);
```

#### .addChip();

Add a chip, including when `allowUserInput` is false. Numeric id `0` is valid.
Ignored if `id` is missing, already present, or the limit is reached.

```text
instance.addChip({
  id: 1337,
  text: 'John Doe',
  image: ''
});
```

#### .deleteChip();

Delete the chip at this index.

```text
instance.deleteChip(3);
```

#### .selectChip();

Focus the chip at this index.

```text
instance.selectChip(2);
```

#### .getData();

The current chips as an array of chip data objects.

```text
instance.getData();
```

#### .destroy();

Destroy the plugin instance, remove rendered chips, and tear down its event handlers. This also destroys the owned Autocomplete and its Menu.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The container the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `chipsData` | Array | The current chip data. |
| `hasAutocomplete` | Boolean | Whether autocomplete is enabled. |
| `autocomplete` | Autocomplete | The Autocomplete instance, if any. |

---

## Checkboxes

Material Design 3 checkboxes, from the HTML.

A `<label>` wrapping `<input type="checkbox">` is the control. The label text is a sibling of the input — no extra class, no required `<span>`. They are CSS only. There is no JavaScript component and nothing to AutoInit.

Tokens follow the [M3 checkbox spec](https://m3.material.io/components/checkbox/specs). The container is 18dp with 2dp corners. Unselected is a 2dp `on-surface-variant` outline. Selected is a `primary` fill with an `on-primary` check. Indeterminate is the same fill with a dash. The state layer is 40dp at 8% hover and 10% focus. The touch target is 48dp. The label is `body-large` / `on-surface`. Disabled is 38%.

Put the input first. Add `checked` or `disabled` on the input. Indeterminate is not an HTML attribute — set `element.indeterminate = true` in script. `filled-in` is a no-op: the selected state is always the filled box.

```html
<label>
  <input type="checkbox">
  Red
</label>
<label>
  <input type="checkbox" checked>
  Yellow
</label>
<label>
  <input type="checkbox" disabled>
  Brown
</label>
```

```js
document.getElementById('indeterminate-checkbox').indeterminate = true;
```

An `input + span` still works if you already have that markup, including the `<label class="checkbox">` pattern. Multiple `<select>` still emits `input + span` that way.

```html
<label class="checkbox">
  <input type="checkbox">
  <span>Yellow</span>
</label>
```

### In a row

Put the labels in a `<div class="inline">` to sit them on one line. A bare group stacks vertically. It is not a `<nav>`: a row of form controls is not navigation, and marking it as one spends a landmark and announces "navigation" over a set of choices.

```html
<div class="inline">
  <label>
    <input type="checkbox" checked>
    Red
  </label>
  <label>
    <input type="checkbox">
    Yellow
  </label>
</div>
```

### Error

Add `aria-invalid="true"` or `class="invalid"` on the input. The box uses `error` / `on-error`.

```html
<label>
  <input type="checkbox" aria-invalid="true">
  Accept the terms
</label>
```

---

## Transfer list

Moves options between an available list and a chosen list, and submits the chosen ones with the form.

A `.transfer-list` holds a `.list.transfer-available`, a `.transfer-actions` group of four `type="button"` buttons, and a `.list.transfer-chosen`. Each row is a `<label>` around a checkbox with a `value` and no `name`. Name each list with `aria-label` or `aria-labelledby`, and give the container a `data-name`. The root bundle starts the behavior on load; with the modular entry, call `TransferList.Init()`. Its listeners are on the document, so a list added later needs no call.

`data-transfer="add"` moves the checked rows of the available list to the end of the chosen list, and `remove` moves the checked rows of the chosen list back. `add-all` and `remove-all` move every row. Moved rows arrive unchecked, and a row whose checkbox is disabled stays where it is. The chosen list keeps the order rows arrived in, and a row moved back returns to the place it had before the first move. The rows are native checkboxes, so Tab reaches each one and Space checks it; there is no listbox role or arrow-key model. A button pressed with nothing to move does nothing, and focus stays on the button after a move.

When the form builds its data, each row of the chosen list adds its checkbox value under the container's `data-name`, in the chosen list's order. A form reset puts every row back where it was before the first move. A list inside a `<fieldset disabled>` submits nothing, as the fieldset's own fields do, but a chosen row whose own checkbox is disabled still submits.

After each move the container fires a bubbling `expressive:transfer` event whose `detail` is `{ rows, from, to }`: the moved rows in their new order, then the two lists. A polite live region after the container announces the move. `Expressive.TransferList.i18n.moved` holds its text, `Moved {count} to {list}` by default, where `{list}` is the destination list's name.

```html
<form>
  <div class="transfer-list" data-name="toppings">
    <ul class="list transfer-available" aria-label="Available toppings">
      <li><label><input type="checkbox" value="olives"> Olives</label></li>
      <li><label><input type="checkbox" value="peppers"> Peppers</label></li>
    </ul>
    <div class="transfer-actions">
      <button type="button" class="icon-button" data-transfer="add" aria-label="Add checked toppings">
        <span class="material-symbols" aria-hidden="true">chevron_right</span>
      </button>
      <button type="button" class="icon-button" data-transfer="add-all" aria-label="Add all toppings">
        <span class="material-symbols" aria-hidden="true">keyboard_double_arrow_right</span>
      </button>
      <button type="button" class="icon-button" data-transfer="remove" aria-label="Remove checked toppings">
        <span class="material-symbols" aria-hidden="true">chevron_left</span>
      </button>
      <button type="button" class="icon-button" data-transfer="remove-all" aria-label="Remove all toppings">
        <span class="material-symbols" aria-hidden="true">keyboard_double_arrow_left</span>
      </button>
    </div>
    <ul class="list transfer-chosen" aria-label="Chosen toppings">
      <li><label><input type="checkbox" value="mushrooms"> Mushrooms</label></li>
    </ul>
  </div>
</form>
```

The lists sit side by side and follow the page direction, and the icons flip in a right-to-left page so they keep pointing at the list they move rows to. Below the medium window size the lists stack, with the buttons in a row between them and their icons turned to point down and up. Each list scrolls once it is taller than `--md-comp-transfer-list-max-block-size` (20rem); `--md-comp-transfer-list-gap` (16px) spaces the parts and `--md-comp-transfer-list-shape` (the medium corner) rounds each list's outline.

The script writes the values, so a page with the script blocked submits nothing for the list. The list must sit inside its form; one that reaches a form through a `form` attribute, or sits in a form inside a shadow root, adds no entries and ignores resets. There is no `required` check, so validate the chosen list on submit when it must not be empty.

## Autocomplete

Autocomplete preserves the original suggestion dataset and selected display label while editing. Escape and Tab close from the input, cancel pending opening, and keep listbox options outside sequential Tab order.

`isOpen` follows the suggestion menu through rerenders and dismissal. Selection
changes emit a bubbling, composed `change` event from the input, so delegated
form listeners can observe them.

Suggest values under a text field as the user types.

Add `autocomplete` to a text input inside a `.field`. `AutoInit()` starts every `.autocomplete` except `no-autoinit`, but the default `data` list is empty — pass options (or call `init`) to give it something to suggest.

Set `isMultiSelect: true` to pick several values. A count appears on the field.

```html
<div class="field">
  <span class="material-symbols prefix" aria-hidden="true">textsms</span>
  <input type="text" id="autocomplete-input" class="autocomplete" placeholder=" ">
  <label for="autocomplete-input">Autocomplete</label>
</div>
```

### Initialization

The IIFE bundle exposes `Expressive.Autocomplete`. `data` is an array of objects:

- id (required) — a string or number. Used as the option text when text is omitted.
- text — display label. The default search matches id and text.
- image — image URL. Shown as a circle; not searched by default.
- description — optional secondary line under the label.
- disabled — `true` keeps the entry in the list, dimmed, but nobody can choose it.
- group — optional heading to list the entry under.

Extra properties are ignored by the default search. Filter them yourself in `onSearch`.

```js
document.addEventListener('DOMContentLoaded', function() {
  const elems = document.querySelectorAll('.autocomplete');
  Expressive.Autocomplete.init(elems, {
    minLength: 0,
    data: [
      { id: 12, text: 'Apple' },
      { id: 13, text: 'Microsoft' },
      { id: 42, text: 'Google', image: 'https://picsum.photos/id/64/250/250' }
    ]
  });
});
```

Multiple selection:

```js
Expressive.Autocomplete.init(document.querySelector('#autocomplete-multi'), {
  minLength: 0,
  isMultiSelect: true,
  data: [
    { id: 12, text: 'Apple' },
    { id: 13, text: 'Microsoft' },
    { id: 42, text: 'Google', image: 'https://picsum.photos/id/64/250/250' }
  ]
});
```

A disabled entry stays in the results so people can see it exists. A click, Enter and `selectOption()` all leave it as it is, so no `change` or `expressive:autocomplete` fires. Up and Down skip it, and the announced result count leaves it out, so a list of only disabled matches announces `i18n.noResults`. The `selected` option and `setMenuItems(items, selected)` can still select one, which shows a choice the person cannot remove:

```js
Expressive.Autocomplete.init(document.querySelector('#autocomplete-disabled'), {
  minLength: 0,
  isMultiSelect: true,
  selected: ['vienna'],
  data: [
    { id: 'lisbon', text: 'Lisbon' },
    { id: 'oslo', text: 'Oslo', disabled: true },
    { id: 'rome', text: 'Rome' },
    { id: 'vienna', text: 'Vienna', disabled: true }
  ]
});
```

Give entries a `group` to list them under labelled headings. Entries with the same group gather where the group first appears in the results, so sort them yourself if the order matters; entries without one stay outside every group. Up and Down pass over a heading to the next entry, the announced result count leaves headings out, and a click on a heading chooses nothing. The heading is plain text, even with `allowUnsafeHTML`:

```js
Expressive.Autocomplete.init(document.querySelector('#autocomplete-groups'), {
  minLength: 0,
  data: [
    { id: 'lisbon', text: 'Lisbon', group: 'Portugal' },
    { id: 'porto', text: 'Porto', group: 'Portugal' },
    { id: 'bergen', text: 'Bergen', group: 'Norway' },
    { id: 'oslo', text: 'Oslo', group: 'Norway' }
  ]
});
```

Custom `onSearch` can load data asynchronously. When the list is ready, call `setMenuItems`. The default filter looks at `id` and `text` only:

```text
onSearch: function(text, autocomplete) {
  const normSearch = text.toLocaleLowerCase();
  autocomplete.setMenuItems(
    autocomplete.options.data.filter(function(option) {
      return option.id.toString().toLocaleLowerCase().includes(normSearch)
        || (option.text && option.text.toLocaleLowerCase().includes(normSearch));
    })
  );
}
```

The third argument is an `AbortSignal` that aborts when the search text changes or the autocomplete is destroyed. Pass it to `fetch()` so a slow response for old text never replaces newer results, and clear the list on other errors. Set `searchDelay` so the request waits for typing to pause instead of going out on every key:

```text
searchDelay: 300,
onSearch: function(text, autocomplete, signal) {
  fetch('/api/search?q=' + encodeURIComponent(text), { signal })
    .then(function(response) { return response.json(); })
    .then(function(items) { autocomplete.setMenuItems(items); })
    .catch(function(error) {
      // A newer search aborted this one; its results are stale.
      if (error.name !== 'AbortError') autocomplete.setMenuItems([]);
    });
}
```

Set `allowCreate` to offer the typed text as a new value. When the trimmed text matches no listed suggestion's `text`, or its `id` when it has no `text`, ignoring case, the list ends in an `Add "text"` row. Choosing it with Enter or a click calls `onCreate` with the trimmed text and fires `expressive:create` with `{ text }`. The selection does not change, so add and select the entry yourself:

```text
allowCreate: true,
onCreate: function(text, autocomplete) {
  const entry = { id: text, text: text };
  autocomplete.options.data.push(entry);
  autocomplete.setMenuItems(autocomplete.options.data, [entry.id], false);
}
```

### Options

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | Array | `[]` | Suggestion list. Each item needs an `id`; `text`, `image`, and `description` are optional. |
| `isMultiSelect` | Boolean | `false` | If true, several values can be selected. `onAutocomplete` receives an array. |
| `maxMenuHeight` | String | `'300px'` | Max height of the suggestion menu. |
| `i18n` | Object | See below | Partial overrides for live status messages. |
| `onAutocomplete` | Function | `null` | Called after a selection (and when a default value is applied). Receives the selected entries. |
| `onSearch` | Function | filters `id` and `text` | Called with the text, the instance and an `AbortSignal` when input events change the search text, including paste, replacement and deletion without key events. Load or filter data, then call `setMenuItems`. The signal aborts when the search text changes or the autocomplete is destroyed. |
| `allowCreate` | Boolean | `false` | If true, text that matches no listed suggestion exactly ends the list in an `Add "text"` row. Choosing it calls `onCreate` and fires `expressive:create`; the selection does not change. |
| `onCreate` | Function | `null` | Called with the trimmed text and the instance when the `Add` row is chosen. |
| `searchDelay` | Number | `0` | Milliseconds to wait after typing pauses before calling `onSearch`, so remote data is fetched once per pause. A search on focus runs at once. Blur, `destroy()` and choosing a single result cancel a pending search. `0` searches on every input. |
| `minLength` | Number | `1` | Characters required before suggestions open. `0` shows the list on click or focus. |
| `menuOptions` | Object | see note | Options for Menu. Defaults include `autoFocus: false`, `closeOnClick: false`, and `coverTrigger: false`. |
| `allowUnsafeHTML` | Boolean | `false` | If true, each data entry's `text` is inserted as HTML. Ids always stay text. Only use sanitized data. |
| `selected` | Array | `[]` | Initial selected ids, strings or numbers. An id without loaded data displays the id. Pass the selected ids to `setMenuItems` with the loaded data to resolve their labels. |

Autocomplete provides a visually hidden, polite live region without changing the visible spinner or selection count. `i18n.loading` defaults to `Loading results.` and `i18n.noResults` to `No results.`. `i18n.results(count)` returns `1 result available.` or `N results available.`; `i18n.selected(count)` returns `1 item selected.` or `N items selected.`. `i18n.create(text)` labels the `Add` row and returns `Add "text"`. Supply functions for language-specific plurals. Messages are inserted as text, never HTML. Missing keys retain defaults.

When a search returns no suggestions, the open list also shows `i18n.noResults` as its first row, so sighted users see what the live region says. The row is `<li class="autocomplete-no-results" role="none" aria-hidden="true">`. It is not an option: arrow keys skip it, and a click on it chooses nothing and leaves focus in the field. It leaves the list when a search returns a match. In Chips, Enter that adds the typed text as a chip closes the list.

Updates announce the count passed to `setMenuItems`, excluding selected items shown as a fallback and the `Add` row. Opening an existing list announces its displayed item count. Announcements require input focus and `minLength`; closing clears the live region and `setMenuItems(..., ..., false)` stays silent. Applications still pass the signal to their requests, so a stale response cannot replace newer results, and own error handling. Results delivered after `destroy()` are ignored, and results delivered after the user leaves the field update the list without opening it. Test actual spoken delivery with a screen reader.

### Methods

> All methods are called on the plugin instance. You can get the instance like this:

```js
const instance = Expressive.Autocomplete.getInstance(elem);
instance.open();
```

#### .open();

Open the suggestion menu if the input meets `minLength`.

```text
instance.open();
```

#### .close();

Close the suggestion menu.

```text
instance.close();
```

#### .selectOption();

Select (or toggle, when multi-select) the entry with this id.

```text
instance.selectOption(42);
```

#### .setMenuItems();

Replace the visible suggestions. Optionally pass selected ids and whether to open the menu (default `true`).

```text
instance.setMenuItems([
  { id: 'Test' },
  { id: 12, text: 'Apple' },
  { id: 13, text: 'Microsoft' },
  { id: 42, text: 'Google', image: 'https://picsum.photos/id/64/250/250' }
]);
```

#### .destroy();

Destroy the plugin instance, remove the menu, and tear down its event handlers.

```text
instance.destroy();
```

### Properties

| Name | Type | Description |
| --- | --- | --- |
| `el` | Element | The input the plugin was initialized with. |
| `options` | Object | The options the instance was initialized with. |
| `isOpen` | Boolean | Whether the suggestion menu is open. |
| `count` | Number | Number of matching options (reset on each input/focus). |
| `activeIndex` | Number | Index of the keyboard-highlighted option, or `-1`. |
| `menu` | Menu | The Menu instance for this autocomplete. |
| `selectedValues` | Array | The currently selected entries. |
