Migrate from Angular Material

Angular Material's components are Angular directives and services; lit-material's are custom elements, which Angular templates use with the schema for them. Here is each component's counterpart, the binding and forms patterns, and what replaces the services.

Set up an Angular app

Install the tokens and the components you use, and load the tokens stylesheet once, from angular.json's styles or your global stylesheet:

npm install @lit-material/tokens @lit-material/button @lit-material/text-field
/* src/styles.css */
@import "@lit-material/tokens/css/index.css";

Import each package for its side effect (it registers the elements) and add CUSTOM_ELEMENTS_SCHEMA to every component whose template uses them:

import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import "@lit-material/button";
import "@lit-material/text-field";

@Component({
  selector: "app-profile",
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <lit-material-text-field label="Name" [value]="name" (input)="name = $any($event.target).value"></lit-material-text-field>
    <lit-material-button variant="filled" [disabled]="!name" (click)="save()">Save</lit-material-button>
  `,
})
export class ProfileComponent {
  name = "";
  save() {}
}

Angular Material's Sass theme doesn't carry over. Set the --md-sys-color-* tokens, or make a palette with the theme builder.

Properties, attributes and events

[value] sets a property, which is how arrays and objects go in ([rows]="rows", [options]="options"); [attr.aria-label] sets an attribute; (change) listens to an event, hyphenated names included: (page-change)="onPage($any($event).detail)". Under CUSTOM_ELEMENTS_SCHEMA Angular doesn't type-check these bindings, and $event.target is a plain EventTarget, hence $any(). Each package's dist/web-types.json and VS Code custom data give completion in templates; see React, Vue and Angular.

Forms

lit-material ships no ControlValueAccessor, so formControlName and ngModel don't work on its elements by themselves. What the elements do, checked in Chromium on the elements themselves (not inside an Angular app):

  • Text field, textarea, slider: fire input on the element with value already updated, and blur reaches the element; setting value updates the field. That is what Angular's DefaultValueAccessor uses (it writes value and listens for input and blur), so ngDefaultControl selects it: <lit-material-text-field label="Email" formControlName="email" ngDefaultControl>. A slider's value is a number.
  • Select: fires only change, so ngDefaultControl would never update the control. Bind [value] and (change), or use an accessor like the one below with value.
  • Checkbox, switch, radio: the state is checked and the event is change: [checked]="on" (change)="on = $any($event.target).checked", or an accessor.

An accessor for the checked-state elements is a few lines in your app (it isn't part of lit-material):

import { Directive, ElementRef, HostListener, forwardRef, inject } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";

@Directive({
  selector: "lit-material-checkbox[formControlName], lit-material-checkbox[formControl], lit-material-checkbox[ngModel], lit-material-switch[formControlName], lit-material-switch[formControl], lit-material-switch[ngModel]",
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckedAccessor), multi: true }],
})
export class CheckedAccessor implements ControlValueAccessor {
  private readonly el = inject<ElementRef<HTMLElement & { checked: boolean; disabled: boolean }>>(ElementRef).nativeElement;
  private onChange = (_: boolean) => {};
  private onTouched = () => {};

  @HostListener("change") changed() { this.onChange(this.el.checked); }
  @HostListener("blur") blurred() { this.onTouched(); }

  writeValue(value: boolean) { this.el.checked = !!value; }
  registerOnChange(fn: (value: boolean) => void) { this.onChange = fn; }
  registerOnTouched(fn: () => void) { this.onTouched = fn; }
  setDisabledState(disabled: boolean) { this.el.disabled = disabled; }
}

The fields validate themselves (required, minlength, pattern, min/max) and show the browser's message once touched; Angular's validators run separately. To show an Angular error on a field, bind [error] and [errorText]. Every form field takes part in a native <form>'s FormData.

Component by component

Angular Materiallit-materialPackageNotes
mat-button, mat-flat-button, mat-stroked-button, mat-raised-button (matButton)lit-material-button@lit-material/buttonvariant: text, filled, outlined, elevated, tonal. href renders a link.
mat-icon-buttonlit-material-icon-button@lit-material/icon-buttonIcon in slot="icon"; toggle + selected for a toggle button. Always give it an aria-label.
mat-fab, mat-mini-fab, extended fablit-material-fab@lit-material/fabsize="small" for mini, extended with label text; color picks the container colour.
mat-form-field + matInputlit-material-text-field, lit-material-textarea@lit-material/text-fieldmat-label → label, mat-hint → supporting-text, mat-error → error-text + error, appearance="outline" → variant="outlined", text prefix/suffix → prefix/suffix, icon prefix/suffix → leading-icon/trailing-icon slots. Numbers: lit-material-number-field.
mat-select + mat-optionlit-material-select, lit-material-select-option@lit-material/selectSingle selection only: gap for multiple (consider lit-material-dual-list-selector or filter chips). No option groups.
mat-autocompletelit-material-autocomplete@lit-material/autocompleteOptions are data: [options]="[{ value, label }]"; free-text accepts values outside the list.
mat-checkboxlit-material-checkbox@lit-material/checkboxLabel text goes in the label attribute; indeterminate as in Angular Material.
mat-slide-togglelit-material-switch@lit-material/switchchecked, change; thumb icons in the icon/selected-icon slots.
mat-radio-group + mat-radio-buttonlit-material-radio@lit-material/radioNo group element (gap): radios with the same name form a group, with arrow-key movement; put them in a fieldset or role="radiogroup" for the group's label.
mat-sliderlit-material-slider@lit-material/sliderOne thumb only: gap for a range slider (matSliderStartThumb/matSliderEndThumb).
mat-chip, mat-chip-listbox, mat-chip-gridlit-material-chip, lit-material-chip-input@lit-material/chipvariant: assist, filter, input, suggestion. A chip grid with an input is lit-material-chip-input (values, change). No listbox element around chips.
mat-button-toggle-grouplit-material-segmented-button-group, lit-material-segmented-button@lit-material/segmented-buttonmultiselect on the group.
MatDialog (service), mat-dialog-*lit-material-dialog, lit-material-confirm-dialog@lit-material/dialog, @lit-material/confirmAn element in your template, not a service: see below. confirm() returns a promise like afterClosed().
mat-menu + matMenuTriggerForlit-material-menu, lit-material-list-item@lit-material/menu, @lit-material/listanchor (an id) or show(trigger); items are lit-material-list-item interactive. Nested menus: gap.
mat-tab-group, mat-tablit-material-tabs, lit-material-tab, lit-material-tab-panel@lit-material/tabsselectedIndex → selected; panels follow the tabs. Tab nav bar for routing: tabs with your router's links.
mat-table (+ MatSort)lit-material-data-table, lit-material-table@lit-material/data-table, @lit-material/tableThe data table sorts and selects by event (sort-change, selection-change): you reorder the rows, as with a server-side MatTableDataSource. No data source class.
mat-paginatorlit-material-data-table-pagination@lit-material/data-tablepage, page-size, total, page-size-options; page-change event.
mat-progress-barlit-material-linear-progress@lit-material/progressvalue/max or indeterminate; no buffer mode (gap).
mat-progress-spinner, mat-spinnerlit-material-circular-progress@lit-material/progressindeterminate for mat-spinner.
MatSnackBar (service)lit-material-snackbar@lit-material/snackbarOne element, show(message): see below.
matTooltip (directive)lit-material-tooltip@lit-material/tooltipAn element with anchor set to the trigger's id.
mat-sidenav, mat-drawerlit-material-navigation-drawer, lit-material-drawer@lit-material/navigation, @lit-material/drawerNavigation drawer: variant="standard" (side) or "modal" (over); lit-material-adaptive-navigation switches drawer, rail and bar by width.
mat-toolbarlit-material-top-app-bar, lit-material-toolbar@lit-material/top-app-bar, @lit-material/toolbarApp bar: variant small, center-aligned, medium, large. A toolbar of actions: lit-material-toolbar.
mat-expansion-panel, mat-accordionlit-material-accordion, lit-material-accordion-panel@lit-material/accordionexpanded, toggle event; multi on the accordion.
mat-stepper, mat-steplit-material-stepper, lit-material-step@lit-material/stepperselected, linear, orientation; step completed/error.
mat-datepicker (+ date range)lit-material-date-picker, lit-material-date-range-picker@lit-material/date-pickerA picker element (modal or docked) with ISO value strings; it isn't attached to an input: open it from a button or a field's trailing icon. No DateAdapter.
mat-treelit-material-tree, lit-material-tree-item@lit-material/treeNested items in markup, expanded/selected, multiple. No tree control or flattener.
matBadge (directive)lit-material-badge@lit-material/badgeAn element you position over the host yourself (value, max).
mat-iconlit-material-icon@lit-material/iconNo icon font or ligatures: register SVG icons with registerIcons() and use name, or slot an <svg>.
mat-cardlit-material-card@lit-material/cardvariant: elevated, filled, outlined; interactive or href for a clickable card.
mat-list, mat-nav-listlit-material-list, lit-material-list-item@lit-material/listSlots leading, trailing, overline, supporting-text; href for nav lists. Selection list: gap (checkboxes in trailing).
mat-dividerlit-material-divider@lit-material/dividerinset-start/inset-end; orientation="vertical".

Services become elements

Dialogs. Put lit-material-dialog in the template and drive it with open; close fires however it closes (button, Escape, backdrop). For a yes/no question, confirm() from @lit-material/confirm creates a dialog, awaits the answer and removes it, much like MatDialog.open(…).afterClosed():

<lit-material-dialog [open]="editing" (close)="editing = false">
  <div slot="headline">Edit profile</div>
  <lit-material-text-field label="Name" formControlName="name" ngDefaultControl></lit-material-text-field>
  <div slot="actions"><lit-material-button variant="text" (click)="editing = false">Cancel</lit-material-button></div>
</lit-material-dialog>
import { confirm } from "@lit-material/confirm";

if (await confirm({ headline: "Delete project?", confirmLabel: "Delete", destructive: true })) this.delete();

Snack bars. Keep one lit-material-snackbar in the app shell and a small service that calls its show(message):

@Injectable({ providedIn: "root" })
export class Snackbar {
  private element?: HTMLElement & { show(message?: string): void };
  attach(element: HTMLElement) { this.element = element as Snackbar["element"]; }
  open(message: string) { this.element?.show(message); }
}

// In the app component, whose template has <lit-material-snackbar #snack></lit-material-snackbar>
private readonly snackbar = inject(Snackbar);
@ViewChild("snack") set snack(ref: ElementRef<HTMLElement>) { this.snackbar.attach(ref.nativeElement); }

Menus and tooltips. They're elements next to their trigger instead of directives on it: <lit-material-menu anchor="more"> opened with (click)="$any(menu).show()" on a template reference #menu, and <lit-material-tooltip anchor="save">Save changes</lit-material-tooltip> after id="save" on the button.

Gaps

  • No ControlValueAccessors and no Angular package: the patterns above, in your app.
  • No codemod: Angular Material templates use directives and services that don't map one to one; use the table.
  • No multiple select, range slider, nested menus, selection list, chip listbox element or radio group element.
  • No CDK equivalents (overlay, portal, a11y, drag and drop, virtual scroll); the data table virtualizes its own rows, and @lit-material/sortable reorders lists.
  • Tables don't own data: no MatTableDataSource, sort or paginator wiring; you sort and slice rows in response to events.
  • Not run inside an Angular app in this repository: the forms behaviour above was checked on the elements in Chromium, against how Angular's DefaultValueAccessor works.