DEV Community

Cover image for Adding Loading Indicators to Angular Material Buttons the Right Way (v22 + Mat Expressive)
Dharmen Shah for Angular Material Dev

Posted on

Adding Loading Indicators to Angular Material Buttons the Right Way (v22 + Mat Expressive)

If you've ever built an async action button in Angular Material — a "Save," "Download," or "Submit" button that needs a spinner while a request is in flight — you've probably hit the same annoying problem: swapping the button's content for a spinner shifts the button's width, causes layout jank, and usually means writing the same @if block over and over across your app.

Angular Material v22 finally fixes this at the source. It adds native progress support to matButton via a showProgress input and a progressIndicator content slot. And if you're using Mat Expressive, the M3 Expressive layer for Angular Material, this new slot is the perfect place to drop in its animated loading indicator — giving you a polished, spring-motion loading state with almost no code.

In this tutorial, we'll walk through both pieces: what Angular Material v22 added, and how to wire up Mat Expressive's loading indicator inside it.

The old way (and why it's annoying)

Before v22, a loading button usually looked something like this:

<button matButton="filled" [disabled]="loading()" (click)="save()">
  @if (loading()) {
    <mat-spinner diameter="20" />
  } @else {
    Save
  }
</button>
Enter fullscreen mode Exit fullscreen mode

This works, but it has a real UX problem: when the spinner replaces the label, the button's content width changes, so the button itself can resize or shift — especially noticeable in button rows or toolbars. Most teams end up hand-rolling a directive just to fix this width issue, which is exactly what the community has been doing for years with things like custom MatButtonLoadingDirectives.

What Angular Material v22 adds

Angular Material v22 introduces two additions to matButton:

  • showProgress — a boolean input that tells the button when to display a progress indicator
  • progressIndicator — a content-projection slot for the actual spinner/indicator element

Here's the key difference from the old approach: the button's normal content stays in the DOM even while the progress indicator is shown. That means the button keeps the same width it had before — no layout jump, no resizing.

A minimal example using Angular Material's own spinner:

<button
  matButton="filled"
  [showProgress]="saving()"
  (click)="save()"
>
  Save
  <mat-progress-spinner
    progressIndicator
    diameter="20"
    mode="indeterminate"
  ></mat-progress-spinner>
</button>
Enter fullscreen mode Exit fullscreen mode

When saving() is true, the spinner appears in place without disturbing the button's layout. When it's false, the button just shows "Save" as normal.

This is a small API surface, but it removes a genuinely common pain point — and it means the loading UI is now something Angular Material understands and manages, rather than something every app reinvents.

Dropping in Mat Expressive's loading indicator

This is where it gets interesting for anyone using Mat Expressive — the library that layers Material Design 3 Expressive styling, spring motion (via GSAP), and extra components on top of Angular Material, without forking or replacing it.

Because the new progressIndicator slot just wants any element marked with the progressIndicator attribute, Mat Expressive's loading indicator drops straight in — no custom directive, no extra wiring, no glue code:

<button
  matExpButton
  size="m"
  matButton="outlined"
  [showProgress]="showProgress()"
  (click)="toggleShowProgress()"
  [style.--mat-exp-loading-indicator-size]="'36px'"
>
  Click to toggle progress
  <mat-exp-loading-indicator
    progressIndicator
    [config]="config()"
    [speed]="speed()"
    [ariaLabel]="ariaLabel() ?? 'Loading'"
  ></mat-exp-loading-indicator>
</button>
Enter fullscreen mode Exit fullscreen mode

That's the entire integration. A few things worth noting:

  • matExpButton is the zero-config directive that applies Mat Expressive's M3 Expressive styling and spring motion to an existing matButton — you don't need to swap components, just add the directive.
  • mat-exp-loading-indicator is Mat Expressive's own animated loading indicator. Because it's marked with progressIndicator, Angular Material's button directive knows to show and hide it automatically based on showProgress.
  • It's configurable via [config] and [speed] inputs, and takes an [ariaLabel] for accessibility (defaulting to "Loading" if not provided).
  • Sizing is controlled via the --mat-exp-loading-indicator-size CSS custom property, set directly on the host button in the example above.
  • The result respects prefers-reduced-motion and is SSR-safe out of the box, since that's baked into Mat Expressive's animation layer.
  • It works identically in light and dark themes with no extra theming work.

Seeing it in action

Rather than describe the animation, it's easier to see it live. Mat Expressive has an interactive playground for this exact component where you can toggle the state and try different configurations directly in the browser:

👉 Loading Indicator Playground

Wrapping up

Angular Material v22's showProgress and progressIndicator slot solve a real, long-standing annoyance: loading buttons that don't jump around. And because the slot is just standard content projection, it composes cleanly with other libraries — like Mat Expressive — that want to plug richer, animated indicators into that same spot.

If you're already on Angular Material v22, upgrading a loading button takes minutes. If you're using Mat Expressive on top of it, you get a considerably nicer-looking result for the same amount of code.

Try it yourself:

Top comments (0)