Angular Material v21: a reusable button component (M3)
Angular Material v21 is here. Material Design 3 is here. This is the missing walkthrough that connects them into a reusable button you can actually ship.
Buttons are easy until they’re not.
The moment you need loading states, icons, variants, and theming that behaves across light and dark mode, “just use MatButton” turns into a bunch of one-off fixes scattered across the codebase.
In this post, I’m building a reusable button component using Angular v21 and Angular Material v21 (Material Design 3). The goal is a button you can drop anywhere and trust.
For the sake of simplicity, this blog post covers only outlined and filled button styles in the primary and secondary variants.
Here’s the plan
I’m splitting the work into three pieces:
- a base directive for inputs and shared behavior
- a loader directive that mounts a spinner only when needed
- a small component that stays boring and reusable
If you want more background on Material theming, I previously covered the full theme setup in Angular Material 19. Many of the same concepts carry over here.
Button component preview
1. The base button directive
This is the foundation. BaseButton holds all shared inputs and computed state, so the component stays simple.
import { booleanAttribute, computed, input, Directive } from '@angular/core';
@Directive({
host: {
'[style.pointer-events]': 'pointerEvents()',
},
})
export abstract class BaseButton {
secondary = input(false, { transform: booleanAttribute });
icon = input<string>();
suffixIcon = input<string>();
loading = input(false, { transform: booleanAttribute });
disabled = input(false, { transform: booleanAttribute });
disableRipple = input(false, { transform: booleanAttribute });
isDisabled = computed(() => this.disabled() || this.loading());
pointerEvents = computed(() => this.isDisabled() ? 'none' : 'auto');
appearance = computed(() => this.secondary() ? 'outlined' : 'filled');
}
What matters here
Signal-based inputs keep state reactive:
icon = input<string>();
loading = input(false, {transform: booleanAttribute});
booleanAttribute lets you write clean HTML-style booleans:
<!-- works the same -->
<app-button [loading]="true">Save</app-button>
<app-button loading="true">Save</app-button>
<app-button loading>Save</app-button>
Computed signals keep derived logic in one place:
isDisabled = computed(() => (this.disabled() || this.loading()));
And the host binding prevents unwanted clicks while button is disabled or loading:
@Directive({
host: {
'[style.pointer-events]': 'pointerEvents()',
},
})
2. The button loader directive
This directive is responsible for the loading experience. When loading is true, it creates a Material spinner and appends it to the button.
import {
Directive,
Renderer2,
input,
inject,
ViewContainerRef,
booleanAttribute,
ComponentRef,
afterRenderEffect,
untracked
} from '@angular/core';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
@Directive({
selector: 'button[loading]',
})
export class ButtonLoaderDirective {
loading = input(false, { transform: booleanAttribute });
private viewContainer = inject(ViewContainerRef);
private renderer = inject(Renderer2);
private spinner: ComponentRef<MatProgressSpinner> | null = null;
private readonly buttonNativeElement = signal<HTMLButtonElement>(
this.viewContainer.element.nativeElement
);
private readonly spinnerSize = computed(() => {
const buttonHeight = this.buttonNativeElement().clientHeight;
return buttonHeight ? Math.trunc(buttonHeight / 1.5) : 20;
})
constructor() {
afterRenderEffect({
earlyRead: () => {
if (this.buttonNativeElement().tagName !== 'BUTTON') {
throw new Error(
`ButtonLoaderDirective can only be used on a button element, but used on${this.buttonNativeElement().tagName}`
);
}
},
write: () => {
const loading = this.loading();
untracked(() => {
if (loading) {
this.buttonNativeElement().classList.add('button-loading');
this.createSpinner();
} else {
this.buttonNativeElement().classList.remove('button-loading');
this.destroySpinner();
}
});
},
});
}
private createSpinner(): void {
if (!this.spinner) {
this.spinner = this.viewContainer.createComponent(MatProgressSpinner);
this.spinner.instance.diameter = this.spinnerSize();
this.spinner.instance.mode = 'indeterminate';
this.spinner.instance.strokeWidth = 2;
const spinnerElement: HTMLElement = this.spinner.location.nativeElement;
this.renderer.setStyle(spinnerElement, 'position', 'absolute');
this.renderer.appendChild(this.buttonNativeElement(), spinnerElement);
}
}
private destroySpinner(): void {
this.spinner?.destroy();
this.spinner = null;
}
}
Understanding afterRenderEffect
This hook is meant for DOM manipulation. It runs after Angular finishes rendering and provide us with four phases (earlyRead, write, mixedReadWrite and read). We will use earlyRead and write phases.
Phase: earlyRead
earlyRead: () => {
// Validate that directive is on correct element
if (this.buttonNativeElement.tagName !== 'BUTTON') {
throw new Error(...);
}
}
Phase: write
write: () => {
const loading = this.loading();
untracked(() => {
if (loading) {
this.buttonNativeElement.classList.add('button-loading');
this.createSpinner();
} else {
this.buttonNativeElement.classList.remove('button-loading');
this.destroySpinner();
}
});
}
Why untracked()?
It prevents reactive dependencies from being created inside an effect. Let's take a look at this example:
If another signal was tracked within afterRenderEffect and subsequently updated inside the createSpinner function, not wrapping it in untracked() would result in an infinite loop:
❌
// Creates infinite loop
write: () => {
if (this.loading()) { // Tracks loading signal
this.createSpinner(); // Might trigger effect (in our case it don't)
}
}
✅
// Reads signal once, no tracking
write: () => {
const loading = this.loading(); // Tracks once
untracked(() => {
if (loading) {
this.createSpinner(); // No tracking inside untracked
}
});
}
3. Dynamic component creation
The spinner is created dynamically using ViewContainerRef.
private createSpinner(): void {
if (!this.spinner) {
// Create component instance
this.spinner = this.viewContainer.createComponent(MatProgressSpinner);
// Configure spinner properties
this.spinner.instance.diameter = this.spinnerSize;
this.spinner.instance.mode = 'indeterminate';
this.spinner.instance.strokeWidth = 2;
// Set spinner styles
const spinnerElement: HTMLElement = this.spinner.location.nativeElement;
this.renderer.setStyle(spinnerElement, 'position', 'absolute');
// Insert into DOM
this.renderer.appendChild(this.buttonNativeElement(), spinnerElement);
}
}
Spinner size stays proportional by tying it to button height:
get spinnerSize(): number {
const buttonHeight = this.buttonNativeElement.clientHeight;
return buttonHeight ? Math.trunc(buttonHeight / 1.5) : 20;
}
4. The button component
With the directives in place, the component stays small.
Component
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { BaseButton } from '../base-button.directive';
import { ButtonLoaderDirective } from '../button-loader.directive';
import { NgClass } from '@angular/common';
@Component({
selector: 'app-button',
templateUrl: 'button.component.html',
styleUrls: ['button.component.scss'],
imports: [NgClass, MatButtonModule, MatIconModule, ButtonLoaderDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent extends BaseButton {}
Template
<button
[ngClass]="{ 'primary': !secondary(), 'secondary': secondary() }"
[matButton]="appearance()"
[disabled]="isDisabled()"
[disableRipple]="disableRipple()"
[loading]="loading()"
>
@if (icon()) { <mat-icon>{{icon()}}</mat-icon> }
<ng-content></ng-content>
@if (suffixIcon()) { <mat-icon iconPositionEnd>{{suffixIcon()}}</mat-icon> }
</button>
5. Dynamic Mat Button appearance
Material buttons use the matButton input. The directive switches between “filled” and “outlined”.
type MatButtonAppearance = 'text' | 'filled' | 'elevated' | 'outlined' | 'tonal';
[matButton]="appearance()" // matButton is MatButtonAppearance type
Equivalent to:
<!-- Primary button -->
<button matButton="filled">...</button>
<!-- Secondary button -->
<button matButton="outlined">...</button>
<!-- Text button -->
<button matButton="text">...</button>
6. Styling with SCSS
This is where M3 theming pays off. The styles are built around mat.button-overrides() and native light-dark().
@use '@angular/material' as mat;
@mixin button($spinner-color-light: #FFFFFF, $spinner-color-dark: #A8ACB1)
{
width: 100%;
&.button-loading {
@include button-loading($spinner-color-light, $spinner-color-dark);
}
}
@mixin button-loading($spinner-color-light, $spinner-color-dark) {
@include mat.progress-spinner-overrides((
active-indicator-color: light-dark(
$spinner-color-light,
$spinner-color-dark
),
));
@include mat.button-overrides((
filled-disabled-label-text-color: transparent,
filled-label-text-color: transparent,
outlined-label-text-color: transparent,
outlined-disabled-label-text-color: transparent,
));
}
:host {
display: contents;
.primary {
@include button;
@include mat.button-overrides((
filled-container-color: light-dark(#4a3b6b, #9b8abf),
filled-label-text-color: #FFFFFF,
filled-disabled-container-color: light-dark(#4a3b6b60, #9b8abf60),
filled-disabled-label-text-color: light-dark(#FFFFFF, #A8ACB1),
filled-label-text-size: 14px,
filled-state-layer-color: light-dark(#5d4d80, #b0a1cf),
filled-hover-state-layer-opacity: 1,
filled-focus-state-layer-opacity: 1,
filled-pressed-state-layer-opacity: 1,
));
}
.secondary {
@include button($spinner-color-light:#082234);
@include mat.button-overrides((
outlined-outline-color: light-dark(#B5BEC5, #A8ACB1),
outlined-label-text-color: light-dark(#082234, #E6E1E3),
outlined-state-layer-color: transparent,
outlined-ripple-color: transparent,
outlined-disabled-outline-color: light-dark(#B5BEC5, #A8ACB180),
));
&:hover, &:active {
@include mat.button-overrides((
outlined-outline-color: light-dark(#475A66, #E6E1E3),
));
}
&:focus, &:active {
@include mat.button-overrides((
outlined-ripple-color: light-dark(#4a3b6b30, #9b8abf40),
outlined-state-layer-color: light-dark(#4a3b6b15, #9b8abf20),
outlined-focus-state-layer-opacity: 1,
));
}
}
}
mat.button-overrides()
@include mat.button-overrides((
filled-container-color: #4a3b6b,
filled-label-text-color: #FFFFFF,
filled-hover-state-layer-opacity: 1,
));
light-dark() and color-scheme
filled-container-color: light-dark(#4a3b6b, #9b8abf)
// ↑ light ↑ dark
// In your global styles
:root {
color-scheme: light dark;
}
display: contents for layout
:host {
display: contents;
// ... other styles
}
This makes the component wrapper “disappear” for layout purposes:
<!-- Without display: contents -->
<div style="display: grid;">
<app-button> <!-- ← Breaks grid -->
<button>Click me</button>
</app-button>
</div>
<!-- With display: contents -->
<div style="display: grid;">
<app-button style="display: contents;"> <!-- ← Transparent for layout -->
<button>Click me</button> <!-- ← Direct grid child -->
</app-button>
</div>
Loading state styling
@mixin button-loading($spinner-color-light, $spinner-color-dark) {
@include mat.button-overrides((
filled-label-text-color: transparent, // Hide text
outlined-label-text-color: transparent,
));
}
7. Understanding state layers
Material uses state layers tokens for interaction feedback.
filled-state-layer-color: #5d4d80, // The overlay color
filled-hover-state-layer-opacity: 1, // Opacity on hover
filled-focus-state-layer-opacity: 1, // Opacity on focus
filled-pressed-state-layer-opacity: 1, // Opacity when pressed
outlined-state-layer-color: transparent, // The overlay color
outlined-focus-state-layer-opacity: 1 // Opacity on focus
8. Usage examples
Basic usage
@Component({
template: `
<app-button (click)="onSave()">
Save
</app-button>
`
})
export class MyComponent {
onSave() {
console.log('Saving...');
}
}
With icons
<!-- Prefix icon -->
<app-button icon="add">
Create New
</app-button>
<!-- Suffix icon -->
<app-button suffixIcon="arrow_forward">
Next Step
</app-button>
<!-- Both -->
<app-button icon="download" suffixIcon="arrow_drop_down">
Export
</app-button>
Loading state
@Component({
template: `
<app-button [loading]="isLoading()" (click)="onClick()">
Submit
</app-button>
`
})
export class MyComponent {
isLoading = signal(false);
onClick() {
this.isLoading.set(true);
// Simulate API call
setTimeout(() => {
this.isLoading.set(false);
}, 2000);
}
}
Disabled state
<app-button [disabled]="!form.valid">
Submit
</app-button>
Secondary variant
<app-button secondary>
Cancel
</app-button>
9. Complete example
@Component({
selector: 'app-root',
template: `
<section class="button-section">
<h2>Primary Buttons</h2>
<div class="button-grid">
<!-- Default -->
<app-button (click)="onClick()" [loading]="isLoading()">
Confirm
</app-button>
<!-- With prefix icon -->
<app-button icon="add" (click)="onClick()" [loading]="isLoading()">
Confirm
</app-button>
<!-- With suffix icon -->
<app-button suffixIcon="add" (click)="onClick()" [loading]="isLoading()">
Confirm
</app-button>
<!-- Loading state -->
<app-button suffixIcon="add" [loading]="true">
Confirm
</app-button>
<!-- Disabled -->
<app-button icon="add" disabled>
Confirm
</app-button>
</div>
</section>
<section class="button-section">
<h2>Secondary Buttons</h2>
<div class="button-grid">
<app-button secondary (click)="onClick()" [loading]="isLoading()">
Cancel
</app-button>
<app-button secondary icon="close">
Dismiss
</app-button>
</div>
</section>
`,
imports: [ButtonComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
isLoading = signal(false);
onClick() {
this.isLoading.set(true);
setTimeout(() => {
this.isLoading.set(false);
}, 2_000);
}
}
10. Tips and gotchas
1. display: contents for layout
:host {
display: contents;
}
2. Pointer events on disabled loading buttons
@Directive({
host: {
'[style.pointer-events]': 'pointerEvents()',
},
})
4. booleanAttribute transform
loading = input(false, {transform: booleanAttribute});
<app-button loading="true">Save</app-button>
<app-button loading>Save</app-button> <!-- HTML style -->
5. OnPush change detection
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
})
6. Standalone everything
@Component({
imports: [NgClass, MatButtonModule, MatIconModule, ButtonLoaderDirective],
// No need for declarations or module imports
})
What’s next?
You could extend this component with:
- Icon-only buttons (no text)
- Button groups and toolbars
- Different sizes (small, medium, large)
- More variants (text, raised, etc.)
- Loading state with progress percentage
Wrapping up
If you’ve built shared UI components in Angular before, you already know where the time goes: repeated edge cases, slightly different variants, and behavior that drifts across the app.
This setup keeps the behavior in one place, keeps the component thin, and lets Material 3 theming do the styling work. The button stays predictable, which is the main thing you want from a shared component.
Resources
Angular Documentation
Material Design 3 Guidelines
Angular Material Components
MDN: light-dark() Function