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
@usesyntax: Replaces the older@importapproach 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 aboutcolor-schemein the official documentation.@include mat.theme(...):
This mixin applies Angular Material’s theming system to thehtmlelement. Here, you’re configuring:
a 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
localStorageto 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
localStorageand 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 ofThemeServicewithout needing to create a class-based initializer function.setTheme()withdefaultTheme:
TheThemeServiceexposesdefaultTheme, which internally checkslocalStorageor 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’sMatButtonToggleGroupandMatButtonTogglecomponents to create a toggle group for the themes. The component usesChangeDetectionStrategy.OnPushto optimize performance by limiting change detection cycles. - Themes and ThemeService Injection
The component injects theThemeServicewhich 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’sid, keeping the UI in sync with the application state. - Theme Change Handling
When a user selects a new theme, the(change)event triggers thethemeChange()method, which updates the theme via theThemeService. 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 theapp-theme-togglecomponent. This makes theme switching instantly accessible and highly visible to the user. - Material Card Example:
Themat-cardcomponent 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 Light, Dark, 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
ThemeServiceupdates the current theme. - The root
<html>element’sclassis updated to eithertheme-light,theme-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
ThemeServicewith persistence vialocalStorage - A reusable
ThemeToggleComponentpowered by Angular Material - Global theming with SCSS
@use+ Material’smat.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.

