DEV Community

Cover image for Let's implement a Theme Switch ๐ŸŽจ like the Angular Material Site
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ for Angular

Posted on • Edited on • Originally published at indepth.dev

Let's implement a Theme Switch ๐ŸŽจ like the Angular Material Site

DISCLAIMER: While I write this article, I'll try to be as vocal as I can about how I implement something. So hopefully, if you're new to Angular or have just started, you should get by just fine. If there's still an issue understanding anything here, please feel to comment on this article about it. I'll be more than happy to elaborate more on it.๐Ÿ™‚

That said, this article is mainly intended for beginners. I've added a TL;DR; below ๐Ÿ‘‡๐Ÿป as a precautionary measure, in case you're a more advanced Angular Developer. Please feel free to navigate to a heading that might interest you more.

TL;DR;

Why a Theme Switch? ๐Ÿคท๐Ÿปโ€โ™‚๏ธ

Alright! So Dark Mode is a not so new cool feature. Most of the websites out there are implementing it as custom themes in their Apps.

And why wouldn't they do that, right? Dark Mode is awesome! It's easier on the eyes, better in terms of power consumption, and can provide an excellent user experience(especially in low-lighting conditions)

Yeah! You heard me. Low-lit ambiances are the best suited for Dark Mode. I mean, nobody's stopping you from changing to Dark Mode even in well-lit environments. But still, Dark Mode is easier on the eyes and makes more sense IN THE DARK.(see what I did there? ๐Ÿ˜‰)

Also, I mentioned Dark Mode as a way to enhance UX right? Well, there are multiple aspects to it. It's always better to offer users a way to customize the look and feel of your Apps, without compromising with your design language. And most of the Web Apps(or Apps, in general), do that via themes.

The other aspect might look more or less like a gimmick to some of you. But you can take the UX of your Web App, to the next level, by intelligently switching themes, based on the ambient lighting conditions that the user is in. I'll get back to this later.

One of my favorite websites, that implement Themes is the Angular Material Site. You might have seen this switch that lets you change the theme on the website.

Theme Switch on material.angular.io

We'll pretty much replicate the same effect in our Angular App. So without further ado, let's get started.

The Setup ๐Ÿงฉ

I've set-up Angular Material on StackBlitz that you can use as a starter template:

From here on, let's add a few Angular Material Components that we can use to see something on the UI. I'll add a toolbar, an icon on it, a menu for theme options, and a button.

Since all these Angular Material Components will be used in my AppModule, it would make sense to create a separate AppMaterialModule that re-exports all the Material related modules from it.

app-material.module.ts

...
import { MatButtonModule } from "@angular/material/button";
import { MatIconModule } from "@angular/material/icon";
import { MatMenuModule } from "@angular/material/menu";
import { MatToolbarModule } from "@angular/material/toolbar";
...

@NgModule({
  exports: [
    MatButtonModule,
    MatIconModule,
    MatMenuModule,
    MatToolbarModule,
  ]
})
export class AppMaterialModule {}
Enter fullscreen mode Exit fullscreen mode

And now I can add the AppMaterialModule to the imports array of my AppModule.

app.module.ts

...
import { AppMaterialModule } from "./app-material.module";
...

@NgModule({
  imports: [
    ...
    AppMaterialModule,
    ...
  ],
  ...
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

NOTE: I'm doing this in here because I will be using all the Angular Material Components exposed by these Angular Material Modules in my AppModule. This would not make much sense in a Real-World Application as we would generally not use all the Angular Material Components in all our Modules. So creating a single AppMaterialModule and then importing it in every Angular Module that you create can lead to performance hits. So you might want to avoid that in such cases.

Moving on, I should now be able to use these Angular Material Components in my App. The look that I'm going for is really simple. THIS ๐Ÿ‘‡๐Ÿป

The Sample App UI

Judging from the image above, we need a HeaderComponent, a MenuComponent that opens upon clicking the ๐ŸŽจ icon and the rest would already be accommodated by our Sample StackBlitz.

Implementing the HeaderComponent:

I plan to make this a smart component.

ASIDE: You can learn more about the Smart and Dumb Component Pattern from this Video ๐Ÿ“บ by Stephen Fluin

Alright, now continuing with our HeaderComponent, it needs to pass on some options for the menu to the MenuComponent. Each option would have things like, backgroundColor, buttonColor, & headingColor for the icon to show on each menu item; and a label, and a value corresponding to each label.

Now we do know that Angular Material has 4 such pre-built themes named:

  • deeppurple-amber.css
  • indigo-pink.css
  • pink-bluegrey.css
  • purple-green.css

So we'll need 4 items for options. To avoid hard-coding of these options in the component itself, I'll just expose this data as a json file and store it in the assets folder in a file named options.json. Doing that will allow me to fetch it with path /assets/options.json

This file would look something like this:

options.json

[
  {
    "backgroundColor": "#fff",
    "buttonColor": "#ffc107",
    "headingColor": "#673ab7",
    "label": "Deep Purple & Amber",
    "value": "deeppurple-amber"
  },
  {
    "backgroundColor": "#fff",
    "buttonColor": "#ff4081",
    "headingColor": "#3f51b5",
    "label": "Indigo & Pink",
    "value": "indigo-pink"
  },
  {
    "backgroundColor": "#303030",
    "buttonColor": "#607d8b",
    "headingColor": "#e91e63",
    "label": "Pink & Blue Grey",
    "value": "pink-bluegrey"
  },
  {
    "backgroundColor": "#303030",
    "buttonColor": "#4caf50",
    "headingColor": "#9c27b0",
    "label": "Purple & Green",
    "value": "purple-green"
  }
]
Enter fullscreen mode Exit fullscreen mode

NOTE: Considering that this data was exposed as a REST API, I can then fetch it using HttpClient in my App. And that's what I'll be doing in this article.

ALTERNATIVELY, you can also leverage it as a static asset instead of exposing this data via a REST API. If you do that, you can import it directly in the HeaderComponent using import options from 'path-to-options.json'.

You'll just have to set resolveJsonModule, and esModuleInterop to true in the compilerOptions, in your tsconfig.app.json/tsconfig.json. I have an implementation for this version in this Sample StackBlitz just in case you're interested. ๐Ÿ™‚

Okay. Let's carry on. Now, since I also have the structure of the option Object, I can create an interface for static typing. Let's store it in a file named option.model.ts:

option.model.ts

export interface Option {
  backgroundColor: string;
  buttonColor: string;
  headingColor: string;
  label: string;
  value: string;
}
Enter fullscreen mode Exit fullscreen mode

Perfect! Now the responsibility of the HeaderComponent is to:

  • Render the header(Obviously! ๐Ÿคท๐Ÿปโ€โ™‚๏ธ)
  • Fetch the options and give it to the MenuComponent.

But we do need to also change the theme at some point. So it's better that we abstract the whole business logic related to themes in a service that I'd call ThemeService. So let's implement that first:

theme.service.ts

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs/Observable";

import { Option } from "./option.model";

@Injectable()
export class ThemeService {
  constructor(
    private http: HttpClient,
  ) {}

  getThemeOptions(): Observable<Array<Option>> {
    return this.http.get<Array<Option>>("assets/options.json");
  }

  setTheme(themeToSet) {
    // TODO(@SiddAjmera): Implement this later
  }
}
Enter fullscreen mode Exit fullscreen mode

Sweet! We can now inject this service as a dependency in the HeaderComponent which would look something like this:

header.component.ts

import { Component, OnInit } from "@angular/core";
import { Observable } from "rxjs/Observable";

import { Option } from "../option.model";
import { ThemeService } from "../theme.service";

@Component({
  selector: "app-header",
  templateUrl: "./header.component.html",
  styleUrls: ["./header.component.css"]
})
export class HeaderComponent implements OnInit  {
  options$: Observable<Array<Option>> = this.themeService.getThemeOptions();

  constructor(private readonly themeService: ThemeService) {}

  ngOnInit() {
    this.themeService.setTheme("deeppurple-amber");
  }

  themeChangeHandler(themeToSet) {
    this.themeService.setTheme(themeToSet);
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the HeaderComponent is also responsible for changing the theme now.

And the template would look like this:

header.component.html

<mat-toolbar color="primary">
  <mat-toolbar-row>
    <span>Dora</span>
    <span class="spacer"></span>
    <app-menu 
      [options]="options$ | async"
      (themeChange)="themeChangeHandler($event)">
    </app-menu>
  </mat-toolbar-row>
</mat-toolbar>
Enter fullscreen mode Exit fullscreen mode

Notice how instead of subscribeing to the options$ Observable in the Component Class, we've used the async pipe to unwrap it. This is a pattern that makes Angular reactive and as far as possible, you should follow this pattern. Once we get the options, we can then pass it as an input to the MenuComponent's options @Input property.

Also, since the responsibility of changing the theme is also taken up by the HeaderComponent, we can implement the MenuComponent as a dumb/presentational component. So let's do that now.

Implementing the MenuComponent:

So now we can tell that the MenuComponent would accept options as an @Input and then iterate through them to render these options. We can also clearly see that it has a themeChange @Output property that calls the handler with the newly selected theme. So we can implement the MenuComponent Class like this:

menu.component.ts

import { Component, EventEmitter, Input, Output } from "@angular/core";

import { Option } from "../option.model";
import { ThemeService } from "../theme.service";

@Component({
  selector: "app-menu",
  templateUrl: "./menu.component.html",
  styleUrls: ["./menu.component.css"]
})
export class MenuComponent {
  @Input() options: Array<Option>;
  @Output() themeChange: EventEmitter<string> = new EventEmitter<string>();

  constructor(private themeService: ThemeService) {}

  changeTheme(themeToSet) {
    this.themeChange.emit(themeToSet);
  }
}
Enter fullscreen mode Exit fullscreen mode

And the template would look something like this:

menu.component.html

<mat-icon
  class="icon" 
  [matMenuTriggerFor]="menu">
  palette
</mat-icon>
<mat-menu #menu="matMenu">
  <button
    *ngFor="let option of options"
    mat-menu-item
    (click)="changeTheme(option.value)">
    <mat-icon
      role="img" 
      svgicon="theme-example"
      aria-hidden="true">
      <svg
        xmlns="http://www.w3.org/2000/svg" 
        xmlns:xlink="http://www.w3.org/1999/xlink" 
        width="100%" 
        height="100%"
        viewBox="0 0 80 80" 
        fit="" 
        preserveAspectRatio="xMidYMid meet" 
        focusable="false">
        <defs>
          <path
            d="M77.87 0C79.05 0 80 .95 80 2.13v75.74c0 1.17-.95 2.13-2.13 2.13H2.13C.96 80 0 79.04 0 77.87V2.13C0 .95.96 0 2.13 0h75.74z"
            id="a">
          </path>
          <path
            d="M54 40c3.32 0 6 2.69 6 6 0 1.2 0-1.2 0 0 0 3.31-2.68 6-6 6H26c-3.31 0-6-2.69-6-6 0-1.2 0 1.2 0 0 0-3.31 2.69-6 6-6h28z"
            id="b">
          </path>
          <path d="M0 0h80v17.24H0V0z" id="c"></path>
        </defs>
        <use xlink:href="#a" [attr.fill]="option.backgroundColor"></use>
        <use xlink:href="#b" [attr.fill]="option.buttonColor"></use>
        <use xlink:href="#c" [attr.fill]="option.headingColor"></use>
      </svg>
    </mat-icon>
    <span>{{ option.label }}</span>
  </button>
</mat-menu>
Enter fullscreen mode Exit fullscreen mode

Alright! Now we have everything in place. We just need a way to switch themes. How do we do that?

Implementing the Theme Switch ๐ŸŽจ

This is the final piece of the puzzle. And we can do this in several different ways. But the Angular Material Website has already implemented this right? And the good thing is, it's open-source. So we do have access to the source code.

So instead of trying to re-invent the wheel, I'm going to cheat a little bit and see how Angular Material Docs App did it.

How Angular Material Website does it? ๐Ÿค”

If you check the actual implementation, they have implemented something called a ThemePicker. This is what we see at the top-right, in the header.

Theme Picker on material.angular.io

This(as the name suggests) is responsible for switching the theme on the website. This component calls a service called StyleManager.

What does this service do, you might ask. Well, when you change the theme from the ThemePicker it:

  • Checks whether there's a link tag on the HTML Document with a class attribute, the value of which is: style-manager-theme:
    • If there isn't such a link tag, it adds this link tag to the head of the document, and then set the href property with the selected theme path on it.
    • If there is such a link tag, it then, it simply sets the href property on this link tag to the selected theme path.

Great, now that we understand what the StyleManager does, I can just copy the StyleManager service in my project. Once I do that, I can just inject this in my ThemeService and call the setStyle method from it with the appropriate values and it should ideally work.

So let's try it out.

Our Implementation

I'll first copy the style-manager.ts in a file named style-manager.service.ts:

style-manager.service.ts

/**
 * Copied from https://github.com/angular/material.angular.io/blob/master/src/app/shared/style-manager/style-manager.ts
 * TODO(@SiddAjmera): Give proper attribution here
 */

import { Injectable } from "@angular/core";

@Injectable()
export class StyleManagerService {
  constructor() {}

  /**
   * Set the stylesheet with the specified key.
   */
  setStyle(key: string, href: string) {
    getLinkElementForKey(key).setAttribute("href", href);
  }

  /**
   * Remove the stylesheet with the specified key.
   */
  removeStyle(key: string) {
    const existingLinkElement = getExistingLinkElementByKey(key);
    if (existingLinkElement) {
      document.head.removeChild(existingLinkElement);
    }
  }
}

function getLinkElementForKey(key: string) {
  return getExistingLinkElementByKey(key) || createLinkElementWithKey(key);
}

function getExistingLinkElementByKey(key: string) {
  return document.head.querySelector(
    `link[rel="stylesheet"].${getClassNameForKey(key)}`
  );
}

function createLinkElementWithKey(key: string) {
  const linkEl = document.createElement("link");
  linkEl.setAttribute("rel", "stylesheet");
  linkEl.classList.add(getClassNameForKey(key));
  document.head.appendChild(linkEl);
  return linkEl;
}

function getClassNameForKey(key: string) {
  return `app-${key}`;
}
Enter fullscreen mode Exit fullscreen mode

Great. So now that I have this service in place, as planned, I'll inject this service as a dependency in my ThemeService and implement the setTheme method:

theme.service.ts

...
import { StyleManagerService } from "./style-manager.service";

@Injectable()
export class ThemeService {
  constructor(
    ...
    private styleManager: StyleManagerService
  ) {}

  ...

  setTheme(themeToSet) {
    this.styleManager.setStyle(
      "theme",
      `node_modules/@angular/material/prebuilt-themes/${themeToSet}.css`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

All I'm doing here is calling the setStyle method from the StyleManagerService with the name of the style key(theme in this case), and the value of the href attribute that it has to set.

The setStyle method, again, either creates a new link tag and then sets the href attribute on it; or updates the href attribute on a pre-existing link tag.

And that's pretty much it. This ๐Ÿ‘‡๐Ÿป is what our final code looks like.

Purrfect! We now have a theme switch, just like the one on the Angular Material Website. And it works as expected ๐Ÿ˜

Next Steps ๐Ÿ‘ฃ

This is all great. But wouldn't it be awesome if our App could automatically switch themes based on the ambient light?๐Ÿค” Well, that's exactly what we're going to do in the next article.

Can't wait? You can read it now. Here's the link:

Closing Notes ๐ŸŽ‰

Awwww! You're still here? Thanks for sticking around. I hope you liked it.

Iโ€™m extremely grateful to Martina Kraus, and Rajat Badjatya for taking the time to proofread it and providing all the constructive feedback in making this article better.

I hope this article has taught you something new related to Angular. If it did hit that ๐Ÿงก/๐Ÿฆ„ icon, and add it to your reading list(๐Ÿ”–). Also, share this article with your friends who are new to Angular and want to achieve something similar.

Please stay tuned while I work on uploading a video version of this article. ๐Ÿ“บ

Icon Courtesy: AngularIO Press Kit | CSS by monkik from the Noun Project

Until next time then. ๐Ÿ‘‹๐Ÿป

Top comments (19)

Collapse
 
negue profile image
negue

Great article and example on how to create a theme-switch :)

With this way node_modules/@angular/material/prebuilt-themes/${themeToSet}.css I see two disadvantages:

  • You need to add the themes to your assets to the build output (node_modules probable not exist anymore when you serve your app outside of the CLI)
  • You need to load (to the browser) the same-ish payload for each theme

IMO, a better way would be to use @johannesjo Helper Library: Demo - GitHub

With this you only load your the theme-boilerplate once and change all colors using CSS-Vars in browser. This also enables to allow your user's to have a custom theme.

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Hi Negue,

Good point. ๐Ÿ˜ƒ I did realise this issue with the approach. I'll probably cover the CSS vars approach in a future article.

Thanks a lot for the feedback ๐Ÿ™Œ๐Ÿป๐Ÿ™‚

Collapse
 
splaktar profile image
Michael Prentice

Thank you for sharing this article and your analysis of reproducing the Angular Material theme picker. I did a good deal of work last year to make it more accessible for screen reader and keyboard users.

However I am a little worried that your final solution may be less accessible (I haven't had time to try it in a screen reader or keyboard yet as I am on my phone).

I would suggest that the menu trigger be a button with the mat-icon-button attribute rather than just a bare mat-icon.

I can understand not replicating our approach for indicating the selected theme menu item, but it would be helpful to somehow indicate which theme is active in your implementation.

Thanks again!

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Ahhh! Great feedback. ๐Ÿ˜ I'll update the article accordingly. ๐Ÿ™‚

Thank you soo much for sharing this and helping me improve the article ๐Ÿ™Œ๐Ÿป

Collapse
 
alexandrutanasescu profile image
Alex

Hi! And what an awesome article! I wish i could've reached it before starting to implement the theme changer from the angular material docs site on my own. This really breaks it down to the core of what the mechanism actually is and makes it easier to understand.

One mention: setting resolveJsonModule and and esModuleInterop to true in the compilerOptions, in
tsconfig.app.json/tsconfig.json does not seem to be all there is to this trick. i am using node 10.15 and npm 6.9.0 with angular 9.1.1 and it still can't import the json as a module. However, this is not an issue due to the fact that you can just declare the options as a static map/list and import it where needed.

I am really curious how you would go about implementing the theme change for custom components -> as in getting access to the $primary and/or $accent in custom component css.

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Hi Alex,

Thank you soo much for your kind words. ๐Ÿ™‚

Regarding implementing the theme change for custom components, I'm afraid I haven't tried it so far.

Whenever I get a chance to do it, I'll probably write another article about it, and update you here. ๐Ÿ™‚

Thanks,
Siddharth

Collapse
 
bhavikcpatel profile image
รŸhรฅvรฏk รพรฅโ€ รชl • Edited

One suggestion, there is no need to specify material modules in AppMaterialModule's imports array if you're just using to export. Once can simply list required modules in export array without importing them.

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Ah! That's a great suggestion. Thank you soo much Bhavik. :)

I've updated the article accordingly.

Collapse
 
gaurangdhorda profile image
GaurangDhorda

i am getting this error...
Refused to apply style from 'localhost:4200/node_modules/@angul...' because its MIME type ('text/html') is not a supported stylesheet MIME type, and strict MIME checking is enabled.

Collapse
 
stephaneeybert profile image
Stephane Eybert

You can't reference a file in the node_modules directory. You could copy the 4 themes under your src directory.

Collapse
 
mdamink profile image
M-Damink

@stephaneeybert What do you mean by this exactly? I also have a mime error. I tried A LOT of possible solutions but I can't get it to work. In my console it says a 404 error..

Collapse
 
amiraziz profile image
amiraziz • Edited

i use your code in our project . thank you.
and question is how can i use theme in sass? with material method i can pass $mat-indigo to component sass, in this method you change it prgrammatically with js. can i use changed theme in component scss file??

Collapse
 
briancodes profile image
Brian

Good job looking into how the Angular site implemented it - I always presumed the were using CSS variables ๐Ÿ‘

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Thank you Brian. Glad you liked the article. ๐Ÿ™‚

Collapse
 
askudhay profile image
Udhay

Well written Siddharth. Good one! ๐Ÿ‘๐Ÿผ๐Ÿ‘๐Ÿผ๐Ÿ™Œ๐Ÿผ

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Thank you Udhay :) Glad you liked it.

Collapse
 
pandiyancool profile image
Pandiyan Murugan

Welcome to Dev.to nanba!

Collapse
 
siddajmera profile image
Siddharth Ajmera ๐Ÿ‡ฎ๐Ÿ‡ณ

Thank you Pandiyan ๐Ÿ™Œ๐Ÿป๐Ÿ™‚

Collapse
 
stephaneeybert profile image
Stephane Eybert

Hello Siddarth,
How did you find the color codes of the 4 prebuilt themes ?
"backgroundColor": "#fff"
Is there anyway to get them programatically ?
Thanks for the article !
Stephane