Automatic Dark Mode Detection in Angular Material

How to make Angular applications ready for Dark Mode

Automatic Dark Mode Detection in Angular Material

Since most operating systems have introduced it, Dark Mode has become more and more established and is already available in many native apps. Web applications, on the other hand, seem not to have reached this trend yet. Therefore, this article shows how Dark Mode can be detected automatically in a web app based on Angular Material and how the color scheme can be adapted accordingly.

Introduction

Angular offers very good support in the design of its UI components. With the help of Angular Material color themes can be created that determine which colors should be used for which component. Two slightly different variants will be described in the following, which allow the implementation of an automatic Dark Mode detection. While the first approach is a solution which is realized exclusively via Sass, the second one extends this concept with TypeScript to provide more programmatic possibilities.

Requirements

First of all, Angular Material should be added to your Angular project. For more instructions follow the Getting started guide.

In addition, we want to define a separate theme.scss file next to the styles.scss file (which should already be created by the Angular CLI). To make use of the Angular Material theme, the theme.scss file should be added to the styles array inside of the angular.json file:

angular.json
"styles": ["src/theme.scss", "src/styles.scss"],

Pure Sass approach

The Pure Sass approach describes a setup that enables an Angular application to automatically adapt to Dark Mode purely with Sass. With Angular Material, themes are defined in the global theme.scss file in the root directory of a typical Angular CLI project.

Themes can either be created using the mixin define-light-theme if a theme with bright colors is desired. If the colors should be rather dark, the define-dark-theme mixin should be used instead. These mixins expect the primary color as the first argument, an accent color as the second, and a warning color can be optionally set as third argument. All other colors of the theme are then derived from these main colors.

Both light and dark theme should be defined like this:

theme.scss
// Imports for Angular Material Theming
@use '@angular/material' as mat;
@include mat.core();

// Theme colors
$theme-primary: mat.define-palette(mat.$indigo-palette);
$theme-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
$theme-warn: mat.define-palette(mat.$red-palette);

// Light theme
$light-theme: mat.define-light-theme(
  (
    color: (
      primary: $theme-primary,
      accent: $theme-accent,
      warn: $theme-warn,
    ),
  )
);

// Dark theme
$dark-theme: mat.define-dark-theme(
  (
    color: (
      primary: $theme-primary,
      accent: $theme-accent,
      warn: $theme-warn,
    ),
  )
);

// Default theme
@include mat.all-component-themes($light-theme);

// Only enabled if dark colors are preferred
@media (prefers-color-scheme: dark) {
  @include mat.all-component-colors($dark-theme);
}

Depending on which color scheme is preferred by the user, the according theme has to be included. For this purpose most browsers support the CSS media feature prefers-color-scheme. It can detect whether a user prefers a light or dark theme in the system settings.

Of course not only the default components of Angular Material should apply the theme, but also our own custom components. For example, there could be a component for our header area called header. Therefore, we're creating a new directory called "themes" and place a header.theme.scss file in it:

themes/header.theme.scss
@use '@angular/material' as mat;

@mixin theme($theme) {
  app-header {
    div {
      padding: 1rem;
      background-color: mat.get-theme-color($theme, primary, 500);
    }
  }
}

Please be aware, that the styles of these theme files are not scoped to a component but to the entire app. This is why all of the styles are wrapped with a "app-header" selector which is the selector of a specific component.

In the next step the header.theme.scss file is imported into the theme.scss file. Another mixin called custom-components-theme wraps all custom component mixins and provides the theme information for them:

theme.scss
// Custom themable components
@use './themes/header.theme' as header;

@mixin custom-components-theme($theme) {
  @include header.theme($theme);
}

The custom-components-theme mixin is now included as well as the angular-material-theme mixin inside the CSS media feature prefers-color-scheme in the theme.scss file:

theme.scss
@include custom-components-theme($light-theme);
@include mat.all-component-themes($light-theme);

@media (prefers-color-scheme: dark) {
  @include custom-components-theme($dark-theme);
  @include mat.all-component-colors($dark-theme);
}

Example App

Below an exemplary Angular application can be found that has implemented the Pure Sass approach. If you enable the Dark Mode in the settings of your operating system, the app will appear in dark colors, otherwise in bright colors.

Dynamic approach

Similar to the Pure Sass approach, the dynamic approach is based on the themes defined in the theme.scss file. The only difference is that the themes are now integrated into the Angular application via TypeScript. It gives a better control over the management of the themes and allows the development of more than two themes. Beyond that, the user can manually change the themes within the application itself.

To achieve that, the themes are made accessible via CSS class for the dark theme in theme.scss:

theme.scss
@include custom-components-theme($light-theme);
@include mat.all-component-themes($light-theme);

.dark-theme {
  @include custom-components-theme($dark-theme);
  @include mat.all-component-colors($dark-theme);
}

Now, the entire logic for managing the themes is getting outsourced to a service called ThemingService which basically consists of two properties:

  • themes List of all available themes
  • theme Name of the theme that is currently used

The property themes provides a list of all available themes that can be used for the application. These themes must of course be defined as CSS classes in the theme.scss file as described above. The current theme property has the type BehaviorSubject so that other components of the application can observe this value and react to changes. It must always be initialized with a value from the beginning - in this case the default theme for the application (e.g. the light-theme).

Initially, the service checks if the value of the media query for prefers-color-scheme matches the Dark Mode. If this is the case, then the dark-theme is used as current theme, otherwise (even if the system does not provide a Dark Mode) the light-theme is used as default theme. With the addListener method the value of the media feature can be observed to update the ThemingService automatically if the preference changes:

theming.service.ts
import { ApplicationRef, Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class ThemingService {
  readonly theme = new BehaviorSubject<'light-theme' | 'dark-theme'>('light-theme');

  constructor(private ref: ApplicationRef) {
    // initially trigger dark mode if preference is set to dark mode on system
    const darkModeOn =
      window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;

    if (darkModeOn) {
      this.theme.next('dark-theme');
    }

    // watch for changes of the preference
    window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
      const turnOn = e.matches;
      this.theme.next(turnOn ? 'dark-theme' : 'light-theme');

      // trigger refresh of UI
      this.ref.tick();
    });
  }

  toggleTheme() {
    this.theme.next(this.theme.value === 'dark-theme' ? 'light-theme' : 'dark-theme');
  }
}

In the HeaderComponent we're providing a button that allows us to switch the theme. To do that we have to inject the ThemingService and use the toggleTheme in the template:

header.component.ts
@Component({
  selector: 'app-header',
  template: `<div class="header">
    <span>Angular Dark mode demo</span>
    <button mat-icon-button (click)="toggleTheme()">
      <mat-icon>light_mode</mat-icon>
    </button>
  </div> `,
  standalone: true,
  imports: [MatButton, MatIcon, MatIconButton],
})
export class HeaderComponent {
  constructor(private readonly themingService: ThemingService) {}

  toggleTheme() {
    this.themingService.toggleTheme();
  }
}

Finally, in the AppComponent (app.component.ts) the ThemingService is injected and in the ngOnInit method the theme property of the service is getting observed. The current theme is stored in the variable currentTheme. This is because it is necessary to remove the theme class from the body before adding the new theme class. With the help of the renderer service, the current theme class is removed from the body and the new theme is added to the document's body:

app.component.ts
export class AppComponent implements OnInit {
  constructor(
    @Inject(DOCUMENT) private document: Document,
    private themingService: ThemingService,
    private renderer: Renderer2,
  ) {}

  ngOnInit() {
    let currentTheme = '';
    this.themingService.theme.subscribe((theme) => {
      if (currentTheme) {
        this.renderer.removeClass(this.document.body, currentTheme);
      }
      currentTheme = theme;
      this.renderer.addClass(this.document.body, theme);
    });
  }
}

Example App

Below an exemplary Angular application can be found that has implemented the dynamic approach. If you enable the Dark Mode in the settings of your operating system, the app will appear in dark colors, otherwise in bright colors. In addition, you can switch the theme manually by clicking on the icon button in the upper right corner of the header:

Conclusion

This article showed how to detect the Dark Mode in an Angular application and how the color scheme of all components can be adapted accordingly by using themes. Basically, the CSS media feature prefers-color-scheme plays a central role, as it allows to detect if the user has requested the system to use a light or dark color theme. Because Angular Material already comes with dark and light themes by default with all components being dependent on them, it is relatively easy to display the entire application in different styles.

In conclusion, it can be said that with Angular it is very well possible to recognize a user’s preferred theme with both a Pure Sass method as well as with a TypeScript variant. This offers great possibilities to make web applications more flexible and more comfortable for the user regarding the visual appearance.