Modern Theming in Angular 20: Light & Dark Mode with Material Design 3

Dark mode is no longer just a trend, it is something users expect. Whether your app requires it or you want to build it in from day one, theme toggling is now a standard feature of modern web apps.

The good news: if you are using Angular 20 and Angular Material with Material Design 3, you already have the tools you need. Angular Material’s SCSS-based theming system makes it possible to define multiple themes, customize colors, and keep styles maintainable. The challenge is that many developers still find theme switching tricky to implement without bloated stylesheets or scattered logic.

In this post, you will learn how to build a clean, scalable theme toggle system with:

  • Angular 20 with SCSS styling
  • Angular Material v20 with Material Design 3
  • A lightweight theme management service

We will cover how to set up Material 3 theming with SCSS, create light and dark color tokens, toggle themes at runtime using a shared service, and persist preferences with localStorage.

By the end, you will have a production-ready light and dark theme setup you can drop into any Angular project.

Prerequisites

Make sure you’ve installed Angular CLI and are using Angular v20 and Angular Material v20 or above:

ng new theming-demo --style=scss
cd theming-demo
ng add @angular/material

Create M3 Themes with system variables

Apply Angular material theme src/styles.scss

/* Import Angular Material styles using the @use rule */
@use '@angular/material' as mat;

/* Set the height of the html and body elements to 100% */
html, body {
  height: 100%;
}

/* Define base styles for the body element */
body {
  margin: 0;
  font-family: Roboto, "Helvetica Neue", sans-serif;
}

/*
 * Define styles for the html element:
 * - Apply a color scheme that supports both light and dark modes.
 * - Include Angular Material theme configuration with custom color, typography, and density settings.
 */
html {
  color-scheme: light dark;

  /* Apply Angular Material theme with custom settings */
  @include mat.theme((
    color: mat.$violet-palette, /* Use violet color palette */
    typography: Roboto,        /* Set typography to Roboto */
    density: 0                 /* Set density to 0 for default spacing */
  ));

  /* Define styles for light theme */
  &.theme-light {
    color-scheme: light;
  }

  /* Define styles for dark theme */
  &.theme-dark {
    color-scheme: dark;
  }
}

Key Features:

  • Modern @use syntax: Replaces the older @import approach and allows better namespacing and modular usage of Angular Material’s theming APIs.
  • color-scheme: light dark: Enables the browser to render native form controls with built-in support for both light and dark appearances. Read more about color-scheme in the official documentation.
  • @include mat.theme(...):
    This mixin applies Angular Material’s theming system to the html element. Here, you’re configuring:
    custom violet color palette (mat.$violet-palette), Roboto typography, default component density (0 = normal spacing).

You can find more about customizing the Angular Material theme in the official documentation.

After setting up theming using Angular Material’s @use-based SCSS and applying the theme via the @include mat.theme(...) mixin, you’ll notice that CSS custom properties (variables) are added directly to the html element at runtime. These look something like this in the browser’s dev tools:

Create a Theme Service

This service will handle applying the correct theme class to the html element and persisting user choice.

Lets create file theme.service.ts

import { effect, Injectable, signal } from '@angular/core';

/**
 * Type representing the possible theme identifiers.
 * - 'sys': System Default theme.
 * - 'light': Light theme.
 * - 'dark': Dark theme.
 */
export type TThemeId = 'sys' | 'light' | 'dark';

/**
 * Interface representing a theme object.
 * @property id - The unique identifier for the theme.
 * @property name - The display name of the theme.
 * @property className - The CSS class name associated with the theme.
 */
export type ITheme = {
  id: TThemeId;
  name: string;
  className: string;
};

@Injectable({
  providedIn: 'root'
})
/**
 * Service for managing application themes.
 * Provides functionality to set, retrieve, and persist themes.
 */
export class ThemeService {
  /**
   * Static array of available themes.
   * Contains predefined themes with their identifiers, names, and CSS class names.
   */
  static readonly themes: ITheme[] = [
    {
      id: 'sys',
      name: 'System Default',
      className: '',
    },
    {
      id: 'light',
      name: 'Light Theme',
      className: 'theme-light',
    },
    {
      id: 'dark',
      name: 'Dark Theme',
      className: 'theme-dark',
    }
  ];

  /**
   * Key used for storing the selected theme in localStorage.
   */
  readonly localStorageKey = 'app-theme';

  /**
   * Signal representing the currently selected theme.
   * Provides a readonly view of the theme state.
   */
  private _currentTheme = signal<ITheme | null>(null);

  /**
   * Readonly signal for accessing the current theme.
   */
  readonly currentTheme = this._currentTheme.asReadonly();

  /**
   * Getter for the default theme.
   * @returns The default theme object.
   */
  get defaultTheme(): ITheme {
    return this._getDefaultTheme();
  }

  /**
   * Sets the current theme.
   * @param theme - The theme to be applied.
   */
  setTheme(theme: ITheme): void {
    this._currentTheme.set(theme);
  }

  /**
   * Effect that synchronizes the current theme with localStorage and updates the document's class list.
   * Automatically triggered when the `currentTheme` signal changes.
   */
  private readonly _currentThemeEffect = effect(() => {
    const theme = this._currentTheme();
    if (!theme) {
      return;
    }
    localStorage.setItem(this.localStorageKey, theme.id);
    document.documentElement.classList.remove(
      ...ThemeService.themes.reduce((acc, item) => {
        if (theme.id !== item.id && !!item.className) {
          acc.push(item.className);
        }

        return acc;
      }, [] as string[])
    );

    if (!!theme?.className) {
      document.documentElement.classList.add(theme.className);
    }
  });

  /**
   * Retrieves the default theme based on the value stored in localStorage.
   * Falls back to the 'System Default' theme if no valid theme is found.
   * @returns The default theme object.
   */
  private _getDefaultTheme(): ITheme {
    const themeId = (localStorage.getItem(this.localStorageKey) as TThemeId) ?? 'sys';
    return ThemeService.themes.find(theme => theme.id === themeId) || ThemeService.themes[0];
  }
}

This Angular service provides a streamlined way to manage application themes, supporting three modes: System Default, Light, and Dark. It leverages Angular’s reactive signal and effect APIs to maintain the current theme state and automatically synchronize it with both the browser’s localStorage and the document’s CSS classes.

Key features include:

  • Type-safe theme definitions: Themes are defined with clear TypeScript types and include an ID, display name, and CSS class for styling.
  • Reactive theme state: The currently active theme is stored as a reactive signal, enabling automatic updates wherever it’s used.
  • Persistence: The service saves the selected theme to localStorage to preserve user preference across sessions.
  • DOM manipulation: When the theme changes, the service updates the <html> element’s class list to apply the corresponding styles dynamically.
  • Default selection logic: On initialization, the service reads from localStorage and falls back gracefully to the System Default theme if none is found.

This approach makes theme management declarative, reactive, and easy to extend—ideal for Angular applications aiming to provide a smooth user experience with customizable appearance.

Theme Initialization at App Startup

To ensure the selected theme is applied immediately at application startup, even before components are rendered, we use Angular’s provideAppInitializer within the ApplicationConfig. This guarantees a consistent and flicker-free user experience from the moment the app loads.

app.config.ts

import {
  ApplicationConfig, 
  inject,
  provideAppInitializer,
} from '@angular/core';

import { ThemeService } from './services/theme.service';

export const appConfig: ApplicationConfig = {
  providers: [
    provideAppInitializer(() => {
      const themeService = inject(ThemeService);
      // Initialize the theme service with the default theme
      themeService.setTheme(themeService.defaultTheme);
    })
  ]
};

Key Concepts:

  • provideAppInitializer():
    This Angular utility allows you to run logic before the app bootstraps. It’s the ideal place to perform one-time setup operations like initializing themes, loading configs, or fetching critical data.
  • inject(ThemeService):
    We use Angular’s functional DI (inject()) to retrieve the singleton instance of ThemeService without needing to create a class-based initializer function.
  • setTheme() with defaultTheme:
    The ThemeService exposes defaultTheme, which internally checks localStorage or falls back to 'sys' (System Default). This theme is immediately applied to the root HTML element before Angular finishes rendering, preventing layout flashes or unstyled content.

Why This Matters:

Without early theme initialization, the application might momentarily render in the wrong theme before switching, causing a visible “flash” effect. By initializing the theme at the application level, we ensure the correct theme is applied right from the start, maintaining a professional and seamless user experience.

Theme Toggle Component to Switch Between Themes

Lets create our them toggle component to switch between themes

theme-toggle.component.ts

import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButtonToggle, MatButtonToggleChange, MatButtonToggleGroup } from '@angular/material/button-toggle';
import { ITheme, ThemeService } from '../services/theme.service';

@Component({
  selector: 'app-theme-toggle',
  imports: [
    MatButtonToggleGroup,
    MatButtonToggle,
  ],
  templateUrl: './theme-toggle.component.html',
  styleUrl: './theme-toggle.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ThemeToggleComponent {
  readonly themeService = inject(ThemeService);

  readonly themes: ITheme[] = ThemeService.themes;

  themeChange(theme: MatButtonToggleChange): void {
    this.themeService.setTheme(this.themes.find(t => t.id === theme.value) || this.themes[0]);
  }
}

theme-toggle.component.html

<mat-button-toggle-group
  name="themeToggle"
  aria-label="Toggle color theme"
  [value]="themeService.currentTheme()?.id"
  (change)="themeChange($event)">
  @for (theme of themes; track theme.id) {
    <mat-button-toggle [value]="theme.id">{{ theme.name }}</mat-button-toggle>
  }
</mat-button-toggle-group>

The ThemeToggleComponent provides a clean and intuitive UI using Angular Material’s toggle buttons to let users select their preferred theme.

  • Component Setup
    It imports Angular Material’s MatButtonToggleGroup and MatButtonToggle components to create a toggle group for the themes. The component uses ChangeDetectionStrategy.OnPush to optimize performance by limiting change detection cycles.
  • Themes and ThemeService Injection
    The component injects the ThemeService which supplies the list of available themes (ITheme[]) and the current active theme.
  • Template and Binding
    The HTML template renders a toggle button for each theme dynamically. The selected toggle corresponds to the current theme’s id, keeping the UI in sync with the application state.
  • Theme Change Handling
    When a user selects a new theme, the (change) event triggers the themeChange() method, which updates the theme via the ThemeService. This causes the application to switch its visual style accordingly.

Putting It All Together in the App Component

To demonstrate the theme switching in action, we integrate the ThemeToggleComponent into the root AppComponent, alongside some Angular Material UI elements for a visually rich interface.

Template Overview (app.html)

<header class="app-header">
  <h1>Angular Material theming</h1>
  <app-theme-toggle/>
</header>

<main class="app-content">
  <mat-card class="example-card" appearance="outlined">
    <mat-card-header>
      <div mat-card-avatar class="example-header-image"></div>
      <mat-card-title>Shiba Inu</mat-card-title>
      <mat-card-subtitle>Dog Breed</mat-card-subtitle>
    </mat-card-header>
    <img mat-card-image src="https://material.angular.dev/assets/img/examples/shiba2.jpg" alt="Photo of a Shiba Inu">
    <mat-card-content>
      <p>
        The Shiba Inu is the smallest of the six original and distinct spitz breeds of dog from Japan.
        A small, agile dog that copes very well with mountainous terrain, the Shiba Inu was originally
        bred for hunting.
      </p>
    </mat-card-content>
    <mat-card-actions>
      <button matButton>LIKE</button>
      <button matButton="filled">SHARE</button>
    </mat-card-actions>
  </mat-card>
</main>

Component Class (app.ts)

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {
  MatCard,
  MatCardActions,
  MatCardContent,
  MatCardHeader,
  MatCardImage,
  MatCardSubtitle, MatCardTitle
} from '@angular/material/card';
import { MatButton } from '@angular/material/button';
import { ThemeToggleComponent } from './theme-toggle/theme-toggle.component';

@Component({
  selector: 'app-root',
  imports: [
    MatButton,
    MatCard,
    MatCardSubtitle,
    MatCardTitle,
    MatCardHeader,
    MatCardImage,
    MatCardContent,
    MatCardActions,
    ThemeToggleComponent
  ],
  templateUrl: './app.html',
  styles: `
    .app-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      width: 100%;
      padding: 24px;
      box-sizing: border-box;
    }

    .app-content {
      padding: 24px;
      box-sizing: border-box;
    }

    .example-card {
      max-width: 400px;
    }

    .example-header-image {
      background-image: url('https://material.angular.dev/assets/img/examples/shiba1.jpg');
      background-size: cover;
    }
  `,
})
export class App {
}

Key Features:

  • Header Section:
    Contains a title and the app-theme-toggle component. This makes theme switching instantly accessible and highly visible to the user.
  • Material Card Example:
    The mat-card component showcases how Angular Material elements respond to theme changes. When a new theme is selected, styles like colors, shadows, and typography update in real-time.

Result

To build and run your Angular application locally during development, you can use the Angular CLI’s ng serve command. It will starts a development server (usually on http://localhost:4200).

ng serve

Are You Ready to See the Magic? Now that we’ve walked through the code, it’s time to see it in action.

In the demo, you’ll notice how toggling between LightDark, and System Default themes triggers an instant visual update across the entire app

Watch the video below:

Behind the scenes, this is happening:

  • Browser-native elements and Material components restyle themselves instantly — without page reloads or re-renders.
  • The ThemeService updates the current theme.
  • The root <html> element’s class is updated to either theme-lighttheme-dark, or nothing (for system default).
  • Angular Material’s CSS variables respond to the change automatically, thanks to the @include mat.theme(...)mixin applied in the global styles.

This dynamic class-switching mechanism is what enables a smooth, flicker-free theme transition experience.

Conclusion

We’ve built a fully functional light/dark theme toggle system in Angular 20 with Material Design 3, using modern Angular features like signal, effect, and provideAppInitializer.

Here’s what we achieved:

  • A reactive, type-safe ThemeService with persistence via localStorage
  • A reusable ThemeToggleComponent powered by Angular Material
  • Global theming with SCSS @use + Material’s mat.theme(...) mixin
  • Seamless, flicker-free theme initialization at app startup

This foundation is clean, scalable, and production-ready. From here, you can extend it with more themes, brand palettes, animations, or OS-level sync.

At Trailhead Technology Partners, we help teams adopt modern Angular and Material practices like this to ship polished, enterprise-grade applications faster. If your team is modernizing an Angular app—or just wants a smoother developer experience—let’s talk.

Picture of Dmytro Litvinov

Dmytro Litvinov

Dmytro Litvinov, a seasoned Front-End Developer. He has been in that role for 9+ years, most of them as an Angular developer. Dmytro has experience in developing web applications and powerful business solutions in different domains: entertainment, trade union platforms, agricultural platforms, blockchain, and others. He knows how to build great software from scratch and has been a technical lead on several projects. His adaptability and domain knowledge have proven instrumental in ensuring the quality of software across varied industries.

Free Consultation

Sign up for a FREE consultation with one of Trailhead's experts.

"*" indicates required fields

This field is for validation purposes and should be left unchanged.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.