/Documentation

React

PublishedUpdated

mtrl/react has a React component for every element: Switch renders <m-switch>, Tabs renders <m-tabs>. The element does the work, so everything in the Web Components guide holds here: the forms, the styling, the top layer. Getting started covers the install and the base stylesheet; this page covers what the React components add.

Components and props

Import the components by name. mtrl/react loads the elements' CSS, and each component registers its element the first time it mounts, so there is no defineAll() to call.

import { Switch, Textfield } from 'mtrl/react';

export function Settings() {
  return (
    <form>
      <Textfield name="city" label="City" supportingText="Where you live" variant="outlined" />
      <Switch name="wifi" defaultChecked>Wi-Fi</Switch>
    </form>
  );
}

The props are the element's attributes and properties in camelCase, typed from the element: supportingText is the supporting-text attribute. Form controls take name. Any other prop, such as id, style, aria-*, data-* or a React event like onClick, goes to the host element. className works on React 18 and 19 alike: the component passes it on as class.

To render another tag prefix, call configure once, before the first render:

import { configure } from 'mtrl/react';

configure({ prefix: 'md' });

Events

Each element event is an on prop: change is onChange, select is onSelect. The handler receives the element's CustomEvent, with the payload in event.detail, typed for each component. They replace React's own handlers of the same name, and keep the element's meaning: a Textfield's onChange follows the native change, when the edit is committed, not React's per-keystroke onChange. Use onInput for every keystroke; both carry { value }.

The component attaches its listeners once and always calls your latest handler, so an inline arrow function costs nothing.

Controlled and uncontrolled

The components follow React's inputs. Give the value prop (checked, value) and it is controlled: the component writes it to the element after every render, and if your handler doesn't change it, puts it back, as React does for <input checked>.

import { useState } from 'react';
import { Switch } from 'mtrl/react';

export function WifiSetting() {
  const [wifi, setWifi] = useState(false);
  return (
    <Switch checked={wifi} onChange={(event) => setWifi(event.detail.checked)}>
      Wi-Fi
    </Switch>
  );
}

Give defaultChecked or defaultValue instead, or nothing, and it is uncontrolled: that prop is the element's default attribute, the element keeps its own state, and a form reset returns to the default. Read the value from onChange, from a ref, or from the form.

The props that work this way are the element's live state: checked on a switch or a checkbox, value on a text field, select, slider, tabs or radio group, and a few more. The other props are settings, which the element follows on every render.

Forms

The form controls are form-associated elements, so a plain <form> sees them: they submit under their name, and new FormData(form) reads them. That makes uncontrolled components a good fit for React 19's form actions, which receive the FormData:

import { Button, Switch, Textfield } from 'mtrl/react';

export function Booking() {
  return (
    <form action={(data) => submitForm(data)}>
      <Textfield name="city" label="City" required />
      <Switch name="newsletter" value="yes">Send me offers</Switch>
      <Button type="submit">Book</Button>
    </form>
  );
}

A required field that is empty blocks the submit, as a native input does, and the form's reset after an action returns each control to its default.

Refs

ref gives you the element, with its properties and methods typed. The element types come from mtrl/elements:

import { useRef } from 'react';
import { Button, Textfield } from 'mtrl/react';
import type { TextfieldElement } from 'mtrl/elements';

export function Rename() {
  const field = useRef<TextfieldElement>(null);
  return (
    <>
      <Textfield ref={field} label="Name" defaultValue="Untitled" />
      <Button onClick={() => field.current?.select()}>Select all</Button>
    </>
  );
}

Methods such as select(), toggle(), show() and close() are the element's, as the component pages list them. focus() is the host's, and moves focus to the control inside.

Children and slots

Children go into the element, as in HTML. Text is the label of a button, switch or checkbox. A composite's items are declaration components, named as their tags: Tab for <m-tab>, MenuItem, SelectOption, Radio. A named slot is a prop of its name that takes JSX, such as a dialog's actions or a card's headerAction; headline takes text for the attribute or JSX for the slot. Each component's page lists its slots.

import { useState } from 'react';
import { Button, Dialog, Tab, Tabs } from 'mtrl/react';

export function Library() {
  const [tab, setTab] = useState<string | null>('songs');
  const [confirming, setConfirming] = useState(false);
  return (
    <>
      <Tabs value={tab} onChange={(event) => setTab(event.detail.value)}>
        <Tab value="songs">Songs</Tab>
        <Tab value="albums">Albums</Tab>
      </Tabs>
      <Dialog
        headline={<strong>Discard draft?</strong>}
        actions={<Button variant="text" onClick={() => setConfirming(false)}>Cancel</Button>}
        open={confirming}
        onClose={() => setConfirming(false)}
      >
        Your changes will be lost.
      </Dialog>
    </>
  );
}

Bare tags in JSX

The components are the usual way. To write the elements themselves (<m-switch checked>), import the tag types once, in any file TypeScript sees:

import type {} from 'mtrl/react/jsx';

export const Wifi = () => (
  <m-switch checked label="Wi-Fi" onchange={(event) => setWifi(event.detail.checked)} />
);

It is types only, so nothing reaches the bundle; the element still needs defineAll() or its define function, as in Web Components. On a bare tag, listen with the lowercase onchange: React 19 hands onChange its own synthetic event, without detail, and React 18 sets no event props on custom elements at all. The components (<Switch onChange>) have none of these differences.

React 18 and 19

The components behave the same on both, and the adapter is checked on both. They set properties and attach listeners themselves, rather than relying on React 19's support for custom elements, and they use forwardRef, so refs work on 18 too. There is nothing to change when you upgrade.

Next.js and server rendering

mtrl/react starts with "use client", so you can import the components from Server Components: they are client components, which Next.js still renders on the server. Event handlers and refs need a client component of your own, as with any client component. The server HTML is the element's tag with its attributes: the string, number and boolean props, a controlled value as its default attribute, and the children. Objects, functions and listeners wait for hydration. The element is registered in the browser on first mount, never at import.

Until then, the server's <m-switch> has no shadow root. Import the pre-upgrade stylesheet once, in the root layout, so each element has its final box from the first paint and nothing shifts when it upgrades:

import type { ReactNode } from 'react';
import 'mtrl/styles/base';
import 'mtrl/elements/preupgrade.css';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

Bundle size

Each component is its own module: importing Switch brings the switch element and its CSS, and nothing else. A component costs about 1 KB gzip more than its web component, and each component page shows its size. For the smallest bundles, the Vanilla factories skip the element layer altogether.