Skip to content
floorPublic

About

Material Design 3 expressive components for the web: as plain JavaScript, as web components, and as React, Vue, Svelte and Solid components. Written in TypeScript, with zero dependencies.

Topics

Resources

Contributing

Stars

10 stars

Watchers

2 watching

Forks

Repository files navigation

material

Material Design 3 components for the web: as web components, as React, Vue, Svelte and Solid components, and as plain JavaScript. Written in TypeScript, with zero dependencies.

Documentation, live playgrounds and examples: md3.io

npm version CI license

<form>
  <m-text-field name="email" type="email" label="Email" required></m-text-field>
  <m-switch name="news" checked>Newsletter</m-switch>
  <m-button type="submit">Sign up</m-button>
</form>

Once the elements are registered (below), these controls take part in the form as native ones do: its value, reset and validation.

Why material

  • Material 3 Expressive. Component sizes, shapes, colours and spring motion follow the Material 3 tokens.
  • One implementation, every stack. The same component is a custom element, a React, Vue, Svelte or Solid component, and a factory, so it looks and behaves the same in each.
  • Measured sizes. Every component has a gzip budget that CI enforces on each pull request, and bundlers keep only what you import. The numbers are under Sizes.
  • Zero dependencies. Installing material installs nothing else. React, Vue, Svelte and Solid are optional peers: material uses the one your app has.
  • Server rendering. Elements and framework components render to declarative shadow DOM on Node and Bun, styled at first paint, and hydrate. See Server rendering.
  • Native behaviour. Form controls are form-associated, and overlays open in the browser's top layer, trap focus and make the page inert where M3 says so.

Install

npm install material

React, Vue, Svelte and Solid are optional peer dependencies: material uses the one your app has and installs none of them.

Use it your way

Way Import from Good for
Web components material/elements Plain HTML, server templates, Angular, any framework
React material/react React 18 and 19, Next.js
Vue material/vue Vue 3, Nuxt
Svelte material/svelte Svelte 5, SvelteKit
Solid material/solid SolidJS, SolidStart
Vanilla factories material The smallest bundles and full control

Every app imports the base stylesheet once: the theme, the tokens a component reads, and the ripple. Type classes and the type scale are material/styles/typography (see Styles).

import 'material/styles/base';

Web components: register them once, then write HTML.

<script type="module">
  import 'material/styles/base';
  import 'material/elements/css';
  import { defineAll } from 'material/elements';
  defineAll();
</script>

<form>
  <m-text-field name="email" type="email" label="Email" required></m-text-field>
  <m-switch name="news" checked>Newsletter</m-switch>
  <m-button type="submit">Sign up</m-button>
</form>

The script imports bare specifiers and stylesheets, so the page goes through a bundler such as Vite. Opened straight from disk, the browser cannot resolve them and the form stays unstyled.

React (Vue, Svelte and Solid work the same way, see below):

import { createRoot } from 'react-dom/client';
import 'material/styles/base';
import { Button, Switch, TextField } from 'material/react';

export function Signup() {
  return (
    <form>
      <TextField name="email" type="email" label="Email" required />
      <Switch name="news" defaultChecked>Newsletter</Switch>
      <Button type="submit">Sign up</Button>
    </form>
  );
}

createRoot(document.getElementById('root')!).render(<Signup />);

React and React DOM are yours to install, and the page has a <div id="root">. In an app that already renders, leave out the first import and the last line, and use <Signup /> where you need it.

Vanilla: each component is a function that returns a DOM element and a small API.

import 'material/styles';
import { createButton, createTextField } from 'material';

const name = createTextField({ label: 'Name' });
const save = createButton({ text: 'Save', variant: 'filled' });
save.disabled.disable();

name.on('input', ({ value }: { value: string }) => {
  if (value.trim()) save.disabled.enable();
  else save.disabled.disable();
});
save.on('click', () => console.log('Saved', name.getValue()));

document.body.append(name.element, save.element);

When the view goes away, release the listeners and the DOM:

name.destroy();
save.destroy();

For hundreds of components created at once, such as a long editable table, use the factories: the elements style a shadow root each, which costs more on the first render. The Web Components guide has the measurement.

material/styles loads every component's styles; for a smaller bundle, import only what you use (see Styles).

Events, methods and cleanup are in the Vanilla guide.

Components

Every component comes three ways:

  • a factory, createButton;
  • an element, <m-button>;
  • a framework component, Button (MButton in Vue).
Group Components
Actions Button, Icon button, Button group, Split button, FAB, Extended FAB, FAB menu, Toolbar
Selection and input Checkbox, Switch, Radio buttons, Chips, Slider, Text field, Select, Search, Date picker, Time picker
Navigation Navigation bar, Navigation rail, Drawer, Tabs, Menu, Top app bar, Bottom app bar
Containment Card, Carousel, List, Divider, Dialog, Bottom sheet, Side sheet
Communication Badge, Progress, Loading indicator, Snackbar, Tooltip

Each component has a page on md3.io with a playground, its options, events and methods. The factories, all exported from material:

Group Factories
Actions createButton, createIconButton, createButtonGroup, createSplitButton, createFab, createExtendedFab, createFabMenu, createToolbar
Selection and input createCheckbox, createSwitch, createRadios, createChips, createSlider, createTextField, createSelect, createSearch, createDatePicker, createTimePicker
Chips, one at a time createAssistChip, createFilterChip, createInputChip, createSuggestionChip
Navigation createNavigationBar, createNavigationRail, createDrawer, createTabs, createTab, createMenu, createTopAppBar, createBottomAppBar
Containment createCard, createCarousel, createList, createDivider, createDialog, createBottomSheet, createSideSheet
Card parts createCardHeader, createCardContent, createCardMedia, createCardActions
Communication createBadge, createProgress, createLoadingIndicator, createSnackbar, createTooltip

Factory control labels

Checkbox, Switch, TextField and Slider factories accept controlLabelOnly: true to put ariaLabel only on the real input or slider handle. The option defaults to false; omitted/false output is unchanged.

Opting in omits the generated wrapper's aria-label, changing its serialized HTML and wrapper [aria-label] selectors. The control keeps its name, focus and form behavior. Authored custom-element host labels remain unchanged, so duplicate host names remain a known limitation.

Styles

The elements and the framework components carry their own styles in their shadow roots. They need only the base stylesheet, for the theme and its tokens. The factories render in the page, so they use the page's stylesheets. Import the full stylesheet once:

import 'material/styles';

Or import the base once, followed by the components you use:

import 'material/styles/base';
import 'material/styles/button';
import 'material/styles/text-field';

// Optional: an alternate theme and the utility classes
import 'material/themes/ocean';
import 'material/styles/utilities';

Choose one or the other, not both. Each selective entry imports what it depends on (the select brings the text field and the menu), so a CSS-capable bundler resolves and deduplicates them. These entries import CSS, so they do not load in plain Node.

What each stylesheet holds:

Import Holds
material/styles/base The baseline theme in light and dark, the colour, shape and typeface tokens, a reset and the ripple
material/styles/base-elements The same theme, tokens, reset and system contrast response as the base, without global ripple rules; for element applications
material/styles/base-lite The baseline theme in light and dark, the colour, shape and typeface tokens, the body theme and the reduced-motion switch; no reset, ripple or contrast modes
material/styles/typography The type scale, the type classes (.mtrl-display-large to .mtrl-label-small), the text utilities, and the styles for headings and paragraphs
material/styles/<component> One component, with what it depends on
material/styles Every component, the typography, and the twelve themes with their contrast levels

Import the typography stylesheet when the page uses the type classes, relies on material's heading styles, or reads a --mtrl-sys-typescale-* token in its own CSS. It has to load after the base; the import takes care of that.

Element applications can use material/styles/base-elements to keep the base's reset and system contrast response while omitting the global ripple rules. Each element's shadow root carries its own ripple CSS. Add material/styles/contrast for explicit baseline contrast attributes, just as with the full base.

material/styles/base-lite is a smaller option for element pages that also omit the page reset and contrast modes: it keeps the theme, tokens, body theme and global reduced-motion switch. Factory components and page-level data-ripple utilities need material/styles/base. Choose one base entry for the page.

Library styles sit in ordered mtrl.* cascade layers, so unlayered application CSS overrides them without specificity battles.

From 3.1.0, Sass consumers can choose which contrast rules to compile with $contrast-emit. Configure the public themes entry in your application stylesheet:

@use "material/src/styles/themes" with ($contrast-emit: none);

Compile with Dart Sass and add your dependencies directory to its load paths, for example:

sass --load-path=node_modules app.scss app.css

For the full stylesheet, use this entry instead:

@use "material/src/styles/main" with ($contrast-emit: none);
Value Rules emitted alongside the standard light and dark roles
all (default) Explicit medium/high contrast attributes and the system contrast preference
explicit Explicit medium/high contrast attributes only
preference The system contrast preference only
none Standard roles only; no contrast attributes or preference rules

Configure the entry before loading any other material theme modules. The setting applies to every theme loaded in that Sass compilation, including standalone themes loaded afterward. This option controls the Sass sources; prebuilt CSS imports keep their existing behavior. Other Sass configuration remains unsupported. Theme with CSS custom properties.

Material describes three contrast levels to accommodate different vision needs. Its color contrast guidance explains the role of contrast in readability. Choosing none keeps standard colors but removes responses to both explicit contrast choices and the system preference.

The Theming guide has the detail: load order, each token, and how to build a theme from a seed colour.

The elements' pre-upgrade rules are a third Sass entry. Until its script defines it, an element is its server-rendered light DOM; these rules give the undefined host the box it will have once the shadow root arrives, scoped to :not(:defined). Configure the tag prefix when the elements use another one:

@use "material/src/styles/elements" with ($tag-prefix: "x");

The prefix defaults to m. The build emits the same rules, at the default prefix, as material/elements/preupgrade.css.

Themes

The baseline theme applies by default and follows the system light or dark preference. Choose a theme and mode on the root element:

<html data-theme="ocean" data-theme-mode="dark">
document.documentElement.dataset.theme = 'ocean';
document.documentElement.dataset.themeMode = 'dark';
Themes Names Where
Baseline and the eleven others baseline, ocean, desert, forest, sunset, spring, summer, autumn, brownbeige, sageivory, tealcaramel, highcontrast In the full stylesheet, and each as material/themes/<name>
M3's scheme variants neutral, vibrant, expressive, fidelity, content, monochrome, rainbow, fruit-salad As material/themes/<name>, and as the Sass source src/styles/themes/_<name>.scss

The scheme variants are not forwarded by the Sass themes entry. They ship as Sass sources, and a theme builder reads them by path, as md3.io does:

@use "material/src/styles/themes/expressive";

Each variant is one file in src/styles/themes, _<name>.scss for the name of its row above.

Every theme has three contrast levels. Set one with data-theme-contrast on the element that carries the theme:

<html data-theme="desert" data-theme-mode="dark" data-theme-contrast="high">
import 'material/styles/base';
import 'material/styles/contrast';
import 'material/themes/desert';
import 'material/themes/desert-contrast';

The attribute is opt-in: the full stylesheet includes it, and selective styles add the two contrast imports above. Without the attribute, the system's prefers-contrast: more selects high contrast, with no import.

How contrast resolves on nested themes, and how each level is derived, is in the Theming guide.

Custom properties

Components read the theme's colour roles, so overriding a role restyles every component that uses it:

:root {
  --mtrl-sys-color-primary: #6750a4;
  --mtrl-sys-color-on-primary: #ffffff;
}

The typefaces and the corner scale are custom properties too, so setting one on :root restyles every component that uses it. Component hooks follow one convention, --mtrl-<component>-<name>:

.brand-slider {
  --mtrl-slider-color: #006a6a;
  --mtrl-slider-on-color: #ffffff;
}

Density is a custom property as well: --mtrl-density steps from 0 to -5 on a container. The classes from .mtrl-density--1 to .mtrl-density--5 set the same steps by name:

.toolbar {
  --mtrl-density: -2;
}

Typography, icons and touch targets keep their size; each component stops at its own height floor, and fixed-height surfaces such as menus and dialogs do not take part. The Theming guide lists the floors.

The Theming guide lists the type, shape and state tokens.

Web components

Overlays open in the browser's top layer from inside their own shadow root, so they keep their styles, trap focus and make the page inert where M3 says so.

Form controls are form-associated. As on native controls, these work:

  • name and the form value;
  • reset and validation;
  • <label for>;
  • back-navigation restore.

Declaration children describe a component's items, as native <select> and <option> do:

<m-tabs value="songs">
  <m-tab value="songs">Songs</m-tab>
  <m-tab value="albums">Albums</m-tab>
</m-tabs>

Attributes are defaults and properties the live state, as on native controls. A checked attribute sets the state until the user or a script changes it, and form.reset() goes back to it.

Element Attribute Values
<m-button> icon-position start (default), end — where the icon attribute's icon sits
<m-button> toggle boolean: a toggle button, dispatching change with { selected, value }
<m-button> selected boolean: the default state, while the selected property is the live one
<m-card> actions-align start, center, end (default), space-between

A card can act as the primary control (clickable) or contain child buttons and links. Material recommends choosing one pattern. Leave clickable off for a card with child actions.

Opt in with promoteChildActions or promote-child-actions when that clickable card should keep its child actions. The card becomes an article, Tab reaches those children, and the development warning stays off.

Without that opt-in, adding actions through the card APIs or named custom-element action slots warns in development when the card is clickable and keeps the configured role, tab stop and activation behavior. Production bundles strip this diagnostic when process.env.NODE_ENV is replaced with "production".

actions-align aligns the card's slotted actions, using the factory's actionsConfig.align values. Start and end follow text direction. Changes after upgrade update the row; removing the attribute restores end. Framework adapters expose actionsAlign.

For pre-upgrade reservation, group multiple controls in one slot="actions" container. Separate actions-slot siblings form a row after upgrade.

Element / factory Attribute / option Values
<m-fab> / createFab placement none (default), top-start, top-end, bottom-start, bottom-end
<m-extended-fab> / createExtendedFab placement Same as FAB
<m-fab-menu> / createFabMenu placement none (default), bottom-start, bottom-end

Placement uses logical sides: end is right in LTR and left in RTL, and follows direction changes. FAB and extended FAB corners are fixed to the viewport with 16px margins; the FAB menu retains its placement within its positioned container.

none keeps the component in the flow. Changes to the element attribute after upgrade recreate the component with the new placement. The framework adapters expose placement with the same name.

For FAB and extended FAB, position remains a deprecated physical alias until 4.0. Its top-right and top-left values keep the same physical sides in RTL. So do bottom-right and bottom-left.

placement wins when both are supplied, including placement="none".

To migrate position="bottom-right", use placement="bottom-end" for the trailing side in LTR and RTL. Keep the physical alias temporarily if the right side is intentional. See Migrating.

The page still loads material's base styles and a theme (see Styles); the elements pick up the theme's tokens.

A server-rendered page sends each element as its tag and light DOM, and the element takes its real look once its script defines it. To reserve its box until then, put the pre-upgrade stylesheet in <head>, after the base styles and theme:

<link rel="stylesheet" href="/node_modules/material/dist/elements/preupgrade.css">

With a bundler, import material/elements/preupgrade.css. What the stylesheet does, its per-element files and its limits are in the Server rendering guide; attributes, events, slots and forms are in the Web Components guide.

The API for writing elements of your own is experimental and may change in a minor release. The elements material defines, and their attributes, properties and events, are not.

Status Names, all from material/elements
Experimental: outside semantic versioning in 3.x defineElement, ElementSpec, registerStyles, hasStyles, SHADOW_BASE_STYLES

React, Vue, Svelte and Solid

The framework components render the elements, so everything above holds: forms, the top layer, the styling. Each entry loads the elements' CSS and registers each element the first time it mounts.

Framework Import Two-way binding Guide
React 18 and 19 import { Switch } from 'material/react' checked + onChange, or defaultChecked React
Vue 3 import { MSwitch } from 'material/vue' v-model Vue
Svelte 5 import { Switch } from 'material/svelte' bind:checked Svelte
Solid import { Switch } from 'material/solid' checked + onChange Solid

Good to know:

  • Each framework is an optional peer dependency; material installs none of them.
  • All four render on the server and hydrate.
  • Angular apps use the elements directly, with CUSTOM_ELEMENTS_SCHEMA.
  • The Svelte entry imports .svelte files, so it needs a bundler and does not load in plain Node.
  • With skipLibCheck: false, use @types/react 18.2.71 or later.

A component owns the on… props of its element's events and its default… props, typed with the element's own payloads. Any other HTML attribute passes to the host.

Each guide covers props and events, controlled and uncontrolled state, named slots, refs and server rendering.

Server rendering

renderElement from material/ssr renders an element to declarative shadow DOM, so the first paint is the component. Each framework has a bridge that makes its components emit the same roots; import it in the server bootstrap:

Framework Bridge
React material/ssr/react
Vue material/ssr/vue
Svelte material/ssr/svelte
Solid material/ssr/solid

Each root carries its styles one of two ways:

  • Inline, the default. The root's whole CSS is in a <style>: styled at first paint in every engine, with no extra request. The HTML is large before compression, so serve it with brotli.
  • Link mode. The root has <link> tags in place of the style text: small HTML, and stylesheets fetched once and cached. WebKit paints the roots unstyled until they arrive.

Node and Bun are supported. Worker and edge runtimes are unsupported in material 3.0.0: there renderElement throws "material/ssr is server-only".

The same HTML policy as Markup and sanitizing applies to the server entry and the four bridges.

The Server rendering guide has the rest: measured page sizes for each mode, the limits of the React and Svelte bridges (Suspense, context), and how a server-rendered host meets the pre-upgrade stylesheet.

Events and overlays

Two rules hold for every factory:

  • A config on* option is the listener registered at creation. It gets the same argument as a listener passed to on(), and runs before one added later.
  • When open() or close() returns, the state has changed and the event has been emitted. Opening an open component, or closing a closed one, does nothing and emits nothing.

The full contract is on md3.io: cancellable events, Escape and stacked modals, the elements' open attribute, the tooltip's exception, and each component's state getter.

Imports and tree-shaking

material publishes ESM only, with type declarations, so bundlers drop unused exports and split dynamic imports. From CommonJS, load it with a dynamic import('material').

Import Path
Component creators import { createButton } from 'material'
One component directly import createSlider from 'material/components/slider'
Component constants import { BUTTON_VARIANTS } from 'material/components/button/constants'
Core utilities import { addClass, removeClass } from 'material/core/dom'

Constants are not exported from the root; import them from the component's constants entry:

import 'material/styles';
import { createButton } from 'material';
import { BUTTON_VARIANTS, BUTTON_SIZES } from 'material/components/button/constants';

const button = createButton({
  text: 'Submit',
  variant: BUTTON_VARIANTS.FILLED,
  size: BUTTON_SIZES.L,
});

document.body.append(button.element);

Some parts load on demand: a button's progress indicator, a card's action buttons, a menu's submenus and the FAB menu's desktop menu. Enable code splitting in your build to keep them out of the initial chunk.

A standalone menu can also defer its implementation with the explicit material/components/menu/deferred entry. This is an opt-in delivery choice; createMenu and the existing element and framework entries keep their synchronous behavior. Enable code splitting and keep factory styles loaded normally:

import createDeferredMenu from 'material/components/menu/deferred';
import 'material/styles/base';
import 'material/styles/menu';

const menu = createDeferredMenu({
  opener: '#actions',
  items: [{ id: 'save', text: 'Save' }],
  on: { loaderror: ({ error }) => console.error(error) },
});
menu.open();             // synchronous requested state and one open event
await menu.preload();    // joins the request; does not open or focus by itself
const surface = menu.element; // real HTMLElement after readiness, otherwise null

The handle mirrors the menu's non-DOM operations: queued setters, event subscriptions, toggling, and access to opener and selection state. Fluent methods and event payloads identify the same handle.

It is not a synchronous MenuComponent. The element property is nullable. Readiness is reported by isReady(), while preload() returns a Promise.

The requested state from isOpen() precedes the cold popup. During that interval, aria-expanded stays false and the opener is busy. Closing cancels presentation without pretending to abort a module fetch. Destroying is terminal and rejects outstanding and future preload calls with AbortError.

A failed import or construction closes the request, emits loaderror and rejects preload(). Explicit subsequent requests may retry; a browser-cached module failure can require a page reload. There is no automatic retry or cache busting. Register an error handler before opening and handle the Promise when awaiting preload. Existing eager menus remain appropriate when DOM access or initially open content must be usable immediately.

For elements, material/elements/menu is the existing menu's dedicated registration boundary. It exports defineMenu plus the menu and item declaration definitions. Importing it registers nothing.

Delay both that entry and material/elements/css/menu. Call defineMenu after both resolve. Import material/elements/preupgrade/menu.css with the page's normal base/theme styles.

The checked application recipe keeps one registration Promise, records cold input once, cancels stale intent and handles failures. It calls toggle(originalEvent) after registration and never redispatches the gesture. The element fixture shows authored items and early host listeners.

Element methods do not exist before upgrade: application callers must await registration. Custom prefixes register their item declarations first. Supply matching pre-upgrade CSS with preupgradeStyles(prefix) from material/elements/preupgrade.

Closed SSR content can defer client registration while keeping its declarative shadow root and styles. Initially open attributes/properties use eager registration and ready styles; no initial-byte saving is claimed for that path. SSR rendering and framework adapters remain synchronous. Factory ARIA references are not copied across the element's shadow boundary.

Deferral shifts initial JavaScript and, for the element recipe, shadow CSS to later requests. It adds a facade or application broker, so fully loaded cost can increase. Keeping another eager menu import can remove the saving. The packed menu-deferred:check compares initial and fully loaded JS/CSS, including shared chunks and the recipe, in the same styled 16-component fixtures.

The date picker's deferred entry

A date picker can bring its calendar on demand too. Import the factory from the component's deferred entry instead of the root:

import { createDeferredDatePicker } from 'material/components/datepicker/deferred';

It is the same picker and the same API: the field, the trigger and the open and close state are there when the factory returns. The calendar is imported on the first open, or on the pointer or focus on the trigger that precedes it.

Until it arrives the dialog has no calendar and no focus into it, and aria-expanded stays false. The dialog is named by aria-label because the title its name points at does not exist yet.

Opening and closing are never held behind the load. The open state answers at once, and so does a close while the calendar is in flight; the calendar mounts when its chunk lands and stays closed. Read the open state rather than the dialog's markup while an open is in flight.

Bundling

The build ships two direction forms. The full stylesheet and the per-component sheets — material/styles and material/styles/<component> — write physical sides with mirrored arms. The properties an old-target bundler rewrites, the inset-inline-* longhands and the logical corner radii, are absent from them, so no bundler pass can put them on the wrong side. Logical margins remain, and an old-target bundler can still rewrite those.

The arms answer dir="rtl" and the mtrl-rtl class on the component itself, on an ancestor, or on a shadow host; a nested mtrl-ltr island stays left to right. What stays logical, such as margin-inline-start, mirrors when dir is set. The marker class does not set the direction. The element sheets and the shadow sheets keep the logical form throughout.

A bundler can still add direction rules of its own when it bundles the logical form. Bun's bundler does when its browser targets predate logical properties: Lightning CSS rewrites each logical declaration into physical ones, guarded by a :lang() list of the right-to-left languages.

Those arms are an approximation: they key on the document's language, not its direction, so a page whose dir and language disagree gets the wrong side. Do not strip them: where language and direction agree, they are what mirrors the layout.

Language alone does not flip the shipped stylesheets. Set dir, or the marker. An Arabic page with neither stays left to right for the placements these sheets arm.

No shipped file carries a :lang() rule. The only :dir() rules are the two badge rules on the tabs element sheet.

Building your own components

Components are composed from small features with pipe. The same building blocks are public:

import { pipe, createBase, withEvents, withElement } from 'material/core/compose';

const createNote = (config: { text?: string } = {}) =>
  pipe(
    createBase,
    withEvents(),
    withElement({ tag: 'div', componentName: 'note' }),
    (component) => ({
      ...component,
      setText(text: string) {
        component.element.textContent = text;
      },
    })
  )(config);

// The component's type is what the pipe builds
type NoteComponent = ReturnType<typeof createNote>;

const note: NoteComponent = createNote();
note.setText('Saved');
document.body.append(note.element);

What the pipe built:

  • the element has the mtrl-note class;
  • withEvents adds the three event methods, on() among them;
  • destroy() removes the element.

Add withLifecycle() from material/core/compose when features need to register cleanup. The Architecture guide explains the building blocks.

Markup and sanitizing

Icons and content options are markup strings, written with innerHTML. Every such write goes through one sink, so you can decide once how markup is treated. Set a policy when the strings can come from users or a CMS, or when your page enforces Trusted Types:

import DOMPurify from 'dompurify';
import { configureHTML } from 'material';

// A sanitizer (DOMPurify here; any function from markup to markup)
configureHTML({ sanitize: (html) => DOMPurify.sanitize(html) });

// Trusted Types: under `require-trusted-types-for 'script'` a plain string
// assignment throws, so return a TrustedHTML from a policy your CSP allows
const policy = window.trustedTypes.createPolicy('app', {
  createHTML: (html) => DOMPurify.sanitize(html),
});
configureHTML({ sanitize: (html) => policy.createHTML(html) });

The policy sees every string, the library's own icons included; a TrustedHTML value passed as an icon or content skips it. With no policy set, markup is written as it is. Text options, such as a button's text, never go through innerHTML.

These attributes are markup. With no policy set they are written as HTML, in the browser and when the server entry or one of the four bridges renders the element. An avatar attribute is not a person's name or an image URL.

On the element:

  • icon on <m-button>
  • icon on <m-extended-fab>
  • icon on <m-fab>
  • icon on <m-icon-button>
  • selected-icon on <m-icon-button> — while toggle and selected
  • expand-icon on <m-navigation-rail> — while the rail is collapsed
  • collapse-icon on <m-navigation-rail> — while the rail is expanded
  • leading-icon on <m-search>
  • trailing-icon on <m-search>
  • avatar on <m-search> — not a person's name or an image URL
  • icon on <m-slider>
  • inset-icon on <m-slider> — size M, L or XL, not a range or centred slider, when the track can hold it
  • inset-icon-at-min on <m-slider> — the same, while the value is at the minimum
  • icon on <m-split-button>
  • icon on <m-switch>
  • leading-icon on <m-text-field>
  • trailing-icon on <m-text-field>

On a declaration child:

  • icon on <m-button-group-item>
  • selected-icon on <m-button-group-item> — an icon-only item, while selected
  • icon on <m-chip>
  • trailing-icon on <m-chip>
  • avatar on <m-chip> — input chips only, and it takes precedence over icon; not a person's name or an image URL
  • icon on <m-drawer-item>
  • selected-icon on <m-drawer-item> — while that destination is active
  • leading-icon on <m-list-item>
  • leading-avatar on <m-list-item> — not a person's name or an image URL
  • trailing-icon on <m-list-item>
  • icon on <m-menu-item>
  • icon on <m-navigation-bar-item>
  • selected-icon on <m-navigation-bar-item> — while that destination is active
  • icon on <m-navigation-rail-item>
  • selected-icon on <m-navigation-rail-item> — while that destination is active
  • icon on <m-search-suggestion>
  • icon on <m-select-option> — written into the menu
  • icon on <m-tab>

The icon of the FAB menu and of its items is markup too. The menu opts out of server rendering, so the server leaves those attributes escaped, and the same policy applies when the component upgrades.

Sizes

What a bundler keeps, minified and gzipped (kB is 1,000 bytes), measured from the packed package by bun run size:check. The table is refreshed at each release, and CI keeps every figure within 2% of the current build:

Import gzip
createButton from material, with code splitting: the initial chunks (the progress indicator loads on demand) 7.6 kB
createButton, without code splitting (the progress indicator included) 13.9 kB
createButton, createTextField and createCheckbox, without code splitting 20.4 kB
Everything the root exports (export * from 'material'), without code splitting 130.8 kB
material/styles/base 3.1 kB
material/styles/base-lite 1.7 kB
material/styles/base and material/styles/button 5.4 kB
material/styles, the full stylesheet 59.6 kB

bun run size measures the initial JavaScript of each of the 37 components and fails when one goes over its budget; CI runs both on every pull request. Each component's page on md3.io gives its size. The measured table — minified, gzipped and deferred, per component against the core kernel — is in SIZES.md.

Browser support

Current Chrome, Edge, Firefox and Safari. The JavaScript targets ES2022, parsed in CI with acorn 8: a parser that stops before ES2022 cannot load the package. The spring motion uses CSS linear() easing (Safari 17.2 or later); older browsers render every component but skip those transitions. The elements use form-associated custom elements and the top layer (popover and <dialog>), both available in every current browser.

Upgrading

Upgrade to the latest mtrl 0.10.x first, then change the package name to material. The migration guide, verified against 3.0.5, starts with silent theme fallbacks and lists renamed and removed APIs, with their replacements.

Earlier upgrades have their own notes: from 0.9.x and the 0.8.0 changes.

Where this came from

The package was published as mtrl up to 0.10.x. The history before 3.0.0 was developed in floor/mtrl; #numbers in commit subjects before 3.0.0 refer to pull requests there.

Versions of material up to 1.0.4 are an earlier, separate library (GPL-3). Its code is on the v1 branch of this repository, and it stays installable with npm install material@legacy.

What a page meets keeps the name:

  • the mtrl- class prefix;
  • the --mtrl- custom properties;
  • the m- tags;
  • the mtrl.* cascade layers.

Contributing

Contributions are welcome. CONTRIBUTING.md covers the development setup, conventions and the distribution checks.

License

MIT, see LICENSE.

Third-party notices: the package ships Material Symbols and Material Icons glyphs, copyright Google LLC, under the Apache License 2.0; NOTICE includes the licence text and lists the files that declare them.

About

Material Design 3 expressive components for the web: as plain JavaScript, as web components, and as React, Vue, Svelte and Solid components. Written in TypeScript, with zero dependencies.

Topics

Resources

Contributing

Stars

10 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages