Angular Material dark / light theme switcher

For my graduation work in Business Informatics, I chose to focus on on UX/UI, because it used to be something I hated and really sucked at. One of my goals was to implement a dark theme switcher in my web app. Although Angular benefits from theme management thanks to Material, I faced some issues during implementation.

I’ll share how to manage a light/dark theme in Angular Material, the different problems I encountered and the solutions I found.

Theme management with Angular Material

Here is the basic code for defining light and dark themes. This code is in the styles.scss file. I’m not going through the setup of Material in an Angular project and the basic explanations about theming so we dive directly into the dark theme question. See the documentation on the Material website for basics.

“Fleet” is the name of my application. I used the option “custom theme” when installing Material on my project, hence the structure of my code which slightly differs from the one shown on the Material website.

// Custom Theming for Angular Material
// For more information: https://material.angular.io/guide/theming
@import '~@angular/material/theming';
@include mat-core();
// Define default light theme
$fleet-light-primary: mat-palette($mat-indigo);
$fleet-light-accent: mat-palette($mat-amber);
$fleet-light-warn: mat-palette($mat-deep-orange);
$fleet-light-theme: mat-light-theme($fleet-light-primary, $fleet-light-accent, $fleet-light-warn);


// Define a dark theme
$fleet-dark-primary: mat-palette($mat-blue-grey);
$fleet-dark-accent:  mat-palette($mat-amber, 900);
$fleet-dark-warn:    mat-palette($mat-deep-orange);
$fleet-dark-theme:   mat-dark-theme($fleet-dark-primary, $fleet-dark-accent, $fleet-dark-warn);
@include angular-material-theme($fleet-light-theme);

This code so far defines both light and dark theme, but light theme is used by default.

Creating the button to switch themes

There are several ways to handle the theme switch, here is how I did it. I chose to use a Material “slide toggle”. Unnecessary parts of the code have been removed for brevity.

First, my app has a component “header”, which is a child of my “app”. The template of my app has a tag to display the app-header and an event binding:

//app.component.html

<mat-sidenav-container>
  <mat-sidenav
    #sidenav
    fixedInViewport
    mode='side'
    opened
    class="side-navbar"
    >
  <!--  Side nav inside content:  -->
    <app-side-nav [sidenavHandle]=sidenav></app-side-nav>
  </mat-sidenav>
  <mat-sidenav-content>
  <!--  Main content of the page:  -->
    <app-header [sidenavHandle]=sidenav (darkThemeOn)="getDarkThemeOn($event)"></app-header>
    <router-outlet></router-outlet>
  </mat-sidenav-content>
</mat-sidenav-container>

A toggle to switch from light to dark theme

In my header component, I chose to use a toggle to switch from light to dark theme, and the other way around.

//header.component.html
<section>

  // some code

  <div class="light-dark-side-placeholder">
    <mat-slide-toggle
      #lightDarkToggle
      class="toggle-light-dark-theme"
      (click)="doToggleLightDark(lightDarkToggle)">
      <mat-icon>{{lightDarkToggleIcon}}</mat-icon>
    </mat-slide-toggle>
  </div>

</section>

The toggle has a handler (#lightDarkToggle) to retrieve its full state, which I pass to a click event. I also added a mat icon which changes at each click: when the app is in light theme, the icon is a moon (suggesting toggle will switch to dark/night mode), and a sun when it’s in dark theme.

Here’s the logic to show the right icon:

//header.component.ts

export class HeaderComponent implements OnInit {
  @Input() sidenavHandle: MatSidenav;
  public title = 'Fleet Management';
  private darkThemeIcon = 'nightlight_round';
  private lightThemeIcon = 'wb_sunny';
  public lightDarkToggleIcon = this.darkThemeIcon;

  constructor() { }

  ngOnInit() {
  }

  public doToggleLightDark(toggle: MatSlideToggle) {
    this.lightDarkToggleIcon = toggle.checked ?  this.darkThemeIcon : this.lightThemeIcon;
  }
}

I store the name of the mat icon in variables (so I can use template binding, thus change dynamically). By default, I chose to display my app in light theme, thus show the icon suggesting dark theme next to the toggle, so I instantiate a variable holding the icon with the darkThemeIcon.

The MatSlideToggle object I retrieved from the template has a boolean “checked” property.

I added the logic to emit the state of the toggle to the app component:

export class HeaderComponent implements OnInit {
  @Input() sidenavHandle: MatSidenav;
  public title = 'Fleet Management';
  private darkThemeIcon = 'nightlight_round';
  private lightThemeIcon = 'wb_sunny';
  public lightDarkToggleIcon = this.darkThemeIcon;
  @Output() darkThemeOn: EventEmitter<boolean> = new EventEmitter<boolean>();

// ...

  public doToggleLightDark(toggle: MatSlideToggle) {
    this.lightDarkToggleIcon = toggle.checked ?  this.darkThemeIcon : this.lightThemeIcon;
    this.darkThemeOn.emit(!toggle.checked);
  }
}

This is simply to communicate from child to parent component, you won’t need this step if your toggle (or any other mechanism to show dark theme) is in the app component.

Note that the toggle is set to “checked” state by default, so when I click it it’s “!checked”. Therefore, I negate the checked status when I emit to the parent because it seems more logical to me to do something if true

Applying the dark theme to the application

Ok now it’s show time! First, let’s make the simple, basic implementation, maybe it’ll work for you without any other adjustment. After, I’ll go through the problems I experienced.

First, in the styles.scss, we have to create classes for our dark and light theme. This is the first version I used (I had to change afterwards, but depending on your app structure it might work as is):

.dark-theme {
  @include angular-material-theme($fleet-dark-theme);
}

.light-theme {
  @include angular-material-theme($fleet-light-theme);
}

Then we will apply it to the app component:

//app.component.ts

import {HostBinding} from '@angular/core';

export class AppComponent {
  public title = 'Fleet Management';
  public darkModeUI = false;

  constructor() {}

  @HostBinding('class')
  public get themeMode() {
    return this.darkModeUI ? 'dark-theme' : 'light-theme';
  }


  public getDarkThemeOn($event) {
    this.darkModeUI = $event;
  }

First, I have a method getDarkThemeOn only to listen to events from the child. When a change is emitted from my header, it sets a variable darkModeUI to true or false. True means “display the dark theme”.

One important thing here is the HostBinding decorator, which allows me to manipulate the CSS classes and apply my theme classes. As per Angular documentation:

Decorator that marks a DOM property as a host-binding property and supplies configuration metadata. Angular automatically checks host property bindings during change detection, and if a binding changes it updates the host element of the directive.

Angular – Host Binding

According to the value of my variable, I apply either my dark-theme or light-theme CSS class globally. Globally, more or less

Problems with dark theme in Angular Material (and solutions!)

These are the workarounds I found, which solved my problems. I’m not saying they’re the best solution but they did the trick and didn’t break my app, plus I found several alike solutions on the web.

The page background color doesn’t change

First, because I use a side-nav, my content is kind of “floating” above the page background, which doesn’t change color when I switch theme. Well, in fact, this was already the case with my light theme, but because my background color is really light I hadn’t noticed πŸ˜‚.

To solve this issue, I used the built-in Renderer2 class. This is again a way to manipulate the DOM, and to my knowledge the only way to be able to manipulate the page CSS properties in an Angular app.

Extend this base class to implement custom rendering. By default, Angular renders a template into DOM. You can use custom rendering to intercept rendering calls, or to render to something other than DOM.

Angular – Renderer2

I have to create CSS classes (still in styles.scss) for the page body background color, which I match with the background color of my dark and light themes:

body.dark {
  background-color: #303030;
}

body.light {
  background-color: #fafafa;
}

And I implement the logic in my app component:

import {HostBinding, Renderer2} from '@angular/core';

export class AppComponent {
  public title = 'Fleet Management';
  public darkModeUI = false;

  constructor(private renderer: Renderer2) {
    this.renderPageBodyColor();
  }

  @HostBinding('class')
  public get themeMode() {
    return this.darkModeUI ? 'dark-theme' : 'light-theme';
  }


  public getDarkThemeOn($event) {
    this.darkModeUI = $event;
    this.renderPageBodyColor();
  }

  private renderPageBodyColor() {
    this.renderer.removeClass(document.body, 'dark');
    this.renderer.removeClass(document.body, 'light');
    this.renderer.addClass(document.body, this.darkModeUI ? 'dark' : 'light');
  }

The renderer is injected in the constructor to be used as a service. Then I created a method that I can call at initialization (I chose to store the user’s them choice), and every time the toggle emits a change.

The current class has to be removed before assigning a new one. To simplify I just remove both class (it prevents me from checking the current class, then working with a conditional structure), then I assign the dark or light class based on the value of my variable.

Mat dialog background became transparent!

My joy quickly faded when I realized that all of a sudden…

All my dialogs backgrounds had become transparent! I found out it’s not because of the renderer or because of the host binding, so I could only blame the CSS file.

I couldn’t find the explanation on the why, but after some manipulations, I realized it was because I didn’t have a default them.

So instead of having two named classes, one for dark and one for light, I simply changed my CSS file to:

@include angular-material-theme($fleet-light-theme);

.dark-theme {
  @include angular-material-theme($fleet-dark-theme);
}

Concretely, it makes my light theme applied everywhere by default, then the dark theme applied when the dark-theme class is applied. That’s how I understand it at least.

So far so great… until I turn dark theme on again…

Dark theme is not applied to mat dialogs

So here’s the deal: mat dialogs (and probably other floating elements) are part of the “overlay container“. And the elements in the overlay container don’t “inherit” the styles of the app (it’s not a child of the main app). So you just have to apply manually the themes to overlay containers (a bit similar to what I had to do with the page body).

I used the built-in OverlayContainer class in app component:

The OverlayContainer provides a handle to the container element in which all individual overlay elements are rendered.

Angular – Overlay

import {HostBinding, Renderer2} from '@angular/core';
import {OverlayContainer} from '@angular/cdk/overlay';

export class AppComponent {
  public title = 'Fleet Management';
  public darkModeUI = false;

  constructor(
     private renderer: Renderer2,
     private overlayContainer: OverlayContainer
  ) {
    this.renderPageBodyColor();
    this.applyThemeToOverlyContainers();
  }

  @HostBinding('class')
  public get themeMode() {
    return this.darkModeUI ? 'dark-theme' : 'light-theme';
  }


  public getDarkThemeOn($event) {
    this.darkModeUI = $event;
    this.renderPageBodyColor();
    this.applyThemeToOverlyContainers();
  }

  private renderPageBodyColor() {
    this.renderer.removeClass(document.body, 'dark');
    this.renderer.removeClass(document.body, 'light');
    this.renderer.addClass(document.body, this.darkModeUI ? 'dark' : 'light');
  }

private applyThemeToOverlyContainers() {
    const overlayContainerClasses = this.overlayContainer.getContainerElement().classList;
    const classesToRemove = Array.from(overlayContainerClasses).filter(item => item.includes('app-theme-'));
    overlayContainerClasses.remove(...classesToRemove); 
    this.overlayContainer.getContainerElement().classList.add(this.darkModeUI ? 'dark-theme' : 'light-theme');
  }

As for the renderer, I created a method called at construction and when a change is emitted. I also injected the overlay container to use it as a service. Similarly to renderer, I had to remove existing classes (theme classes) before reapplying one. I found a method that does it without explicit mention, although I think using the same logic as with the renderer would perfectly work.

Perfecting dark theme

Everything’s fine now!

A few more things you might consider:

  • Change the primary/accent/warn colors to make it more harmonious and avoid hurting the user’s eyes with crazy contrasts.
  • Override some properties in the CSS based on applied theme: it can be done in the theme class (see next code snippet).
  • Make a local storage to save the user’s choice, so they will see the dark theme as soon as they open the app if that’s what they chose. You might also save the info in a database so it’s not relying on the browser.
.dark-theme {
  @include angular-material-theme($fleet-dark-theme);
 // overriding some CSS properties when dark theme is on:
  .mat-drawer-inner-container {
    background-color: #000033;
  }
  .mat-table .mat-footer-cell, .mat-table .mat-header-cell {
    color: lightgray !important;
  }
  .warn-discrepancy {
    background: #BF360C !important;
  }
}

I hope this was useful! Drop your comments and questions below, subscribe to receive future articles or follow me on LinkedIn.

My full project is available on GitHub. Please keep in mind this was a school projects. My project was using Angular 8.

Also note that many problems were solved in newer versions of Angular. Upgrade your version if you can before going through those workarounds.

Leave a Reply

Your email address will not be published. Required fields are marked *

Skip to content