DEV Community

Cover image for Master Dynamic Theming in Angular: Build Scalable Theme Architecture with SCSS
Rajat
Rajat

Posted on

Master Dynamic Theming in Angular: Build Scalable Theme Architecture with SCSS

Ever spent hours wrestling with CSS variables, only to realize your dark mode still looks like a hot mess? ๐ŸŒš

You're not alone. I've been thereโ€”staring at a codebase where changing one color meant hunting through 47 different files. Sound familiar?

By the end of this article, you'll learn how to:

  • Build a bulletproof theming system that scales with your team
  • Switch themes dynamically without page refreshes
  • Structure SCSS that even your future self will thank you for
  • Write unit tests that actually catch theme-related bugs
  • Avoid the pitfalls that cost me weeks of refactoring

Quick question before we dive in: What's your current theme-switching setup? Drop a comment belowโ€”I'm genuinely curious if anyone else is using the "find-and-replace" method I started with. ๐Ÿ˜…


Why Most Angular Theme Implementations Fail

Let's be real for a second. Most tutorials show you how to toggle between light and dark mode with a simple CSS class. Cool story, but what happens when:

  • Your designer drops 5 new brand themes on your desk?
  • Marketing wants seasonal themes for holidays?
  • You need to support white-label clients with custom colors?

That's when the simple tutorials fall apart, and you're left with spaghetti CSS that nobody wants to touch.

Here's what we're building instead: A proper theme architecture that treats themes as first-class citizens in your Angular app.


The Foundation: Setting Up Your Theme Architecture

First, let's structure our SCSS files properly. This isn't just about organizationโ€”it's about survival when your app grows.

Step 1: Create Your Theme Structure

// styles/
// โ”œโ”€โ”€ themes/
// โ”‚   โ”œโ”€โ”€ _variables.scss
// โ”‚   โ”œโ”€โ”€ _light-theme.scss
// โ”‚   โ”œโ”€โ”€ _dark-theme.scss
// โ”‚   โ””โ”€โ”€ _theme-mixin.scss
// โ”œโ”€โ”€ core/
// โ”‚   โ””โ”€โ”€ _theming.scss
// โ””โ”€โ”€ styles.scss

Enter fullscreen mode Exit fullscreen mode

Step 2: Define Your Theme Variables

// _variables.scss
// Define your theme structure as a map
$theme-properties: (
  // Primary colors
  primary: null,
  primary-light: null,
  primary-dark: null,

  // Background colors
  bg-primary: null,
  bg-secondary: null,
  bg-tertiary: null,

  // Text colors
  text-primary: null,
  text-secondary: null,
  text-muted: null,

  // UI elements
  border-color: null,
  shadow-color: null,

  // Status colors
  success: null,
  warning: null,
  error: null,
  info: null
);

Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก Pro tip: Define ALL your theme properties upfront. Trust me, adding them later is a pain when you have 20+ components already using the theme.

Step 3: Create Your Theme Definitions

// _light-theme.scss
$light-theme: (
  primary: #3f51b5,
  primary-light: #7986cb,
  primary-dark: #303f9f,

  bg-primary: #ffffff,
  bg-secondary: #f5f5f5,
  bg-tertiary: #e0e0e0,

  text-primary: #212121,
  text-secondary: #757575,
  text-muted: #9e9e9e,

  border-color: #e0e0e0,
  shadow-color: rgba(0, 0, 0, 0.1),

  success: #4caf50,
  warning: #ff9800,
  error: #f44336,
  info: #2196f3
);

// _dark-theme.scss
$dark-theme: (
  primary: #7986cb,
  primary-light: #aab6fe,
  primary-dark: #49599a,

  bg-primary: #121212,
  bg-secondary: #1e1e1e,
  bg-tertiary: #2c2c2c,

  text-primary: #ffffff,
  text-secondary: #b3b3b3,
  text-muted: #666666,

  border-color: #333333,
  shadow-color: rgba(0, 0, 0, 0.5),

  success: #66bb6a,
  warning: #ffa726,
  error: #ef5350,
  info: #42a5f5
);

Enter fullscreen mode Exit fullscreen mode

Quick check: Are you already thinking about that custom client theme you need to support? Good! This structure makes it dead simple to add new themes. ๐Ÿ‘‡


The Magic: Dynamic Theme Switching with Angular Services

Now for the fun partโ€”making this actually work in Angular.

Step 4: Create the Theme Service

// theme.service.ts
import { Injectable, Inject, RendererFactory2, Renderer2 } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject, Observable } from 'rxjs';

export type Theme = 'light' | 'dark' | 'custom';

@Injectable({
  providedIn: 'root'
})
export class ThemeService {
  private renderer: Renderer2;
  private currentTheme$ = new BehaviorSubject<Theme>('light');

  constructor(
    @Inject(DOCUMENT) private document: Document,
    rendererFactory: RendererFactory2
  ) {
    this.renderer = rendererFactory.createRenderer(null, null);
    this.initializeTheme();
  }

  private initializeTheme(): void {
    // Check for saved theme preference or default to 'light'
    const savedTheme = localStorage.getItem('preferred-theme') as Theme;
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

    const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
    this.setTheme(initialTheme);

    // Listen for system theme changes
    window.matchMedia('(prefers-color-scheme: dark)')
      .addEventListener('change', (e) => {
        if (!savedTheme) { // Only auto-switch if user hasn't set preference
          this.setTheme(e.matches ? 'dark' : 'light');
        }
      });
  }

  setTheme(theme: Theme): void {
    // Remove existing theme classes
    const themeClasses = ['light-theme', 'dark-theme', 'custom-theme'];
    themeClasses.forEach(className => {
      this.renderer.removeClass(this.document.body, className);
    });

    // Add new theme class
    this.renderer.addClass(this.document.body, `${theme}-theme`);

    // Save preference
    localStorage.setItem('preferred-theme', theme);
    this.currentTheme$.next(theme);

    // Dispatch custom event for components that need to know
    this.document.dispatchEvent(
      new CustomEvent('themeChanged', { detail: theme })
    );
  }

  getCurrentTheme(): Observable<Theme> {
    return this.currentTheme$.asObservable();
  }

  toggleTheme(): void {
    const current = this.currentTheme$.value;
    const next = current === 'light' ? 'dark' : 'light';
    this.setTheme(next);
  }
}

Enter fullscreen mode Exit fullscreen mode

Step 5: The SCSS Magic Mixin

Here's where we tie it all together:

// _theme-mixin.scss
@mixin apply-theme($theme-map) {
  // Generate CSS custom properties from theme
  @each $property, $value in $theme-map {
    --theme-#{$property}: #{$value};
  }
}

// Helper function to use theme colors
@function theme-color($color-name) {
  @return var(--theme-#{$color-name});
}

// _theming.scss
@import './themes/variables';
@import './themes/light-theme';
@import './themes/dark-theme';
@import './themes/theme-mixin';

// Apply themes to body classes
body.light-theme {
  @include apply-theme($light-theme);
}

body.dark-theme {
  @include apply-theme($dark-theme);
}

// Global theme-aware styles
body {
  background-color: theme-color(bg-primary);
  color: theme-color(text-primary);
  transition: background-color 0.3s ease, color 0.3s ease;
}

Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ฌ Real talk: Have you ever tried to implement smooth theme transitions? That little transition property makes all the difference. Your users will think you're a wizard.


Component Integration: Making Your Components Theme-Aware

Step 6: Using Themes in Components

// button.component.scss
@import 'src/styles/themes/theme-mixin';

.btn {
  background-color: theme-color(primary);
  color: theme-color(bg-primary);
  border: 1px solid theme-color(border-color);
  padding: 12px 24px;
  border-radius: 4px;
  transition: all 0.3s ease;

  &:hover {
    background-color: theme-color(primary-dark);
    box-shadow: 0 4px 8px theme-color(shadow-color);
  }

  &.btn-secondary {
    background-color: theme-color(bg-secondary);
    color: theme-color(text-primary);
  }

  &.btn-danger {
    background-color: theme-color(error);
    color: white;
  }
}

Enter fullscreen mode Exit fullscreen mode

Step 7: Theme Toggle Component

// theme-toggle.component.ts
import { Component, OnInit } from '@angular/core';
import { ThemeService, Theme } from '../services/theme.service';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-theme-toggle',
  template: `
    <button
      class="theme-toggle"
      [attr.aria-label]="'Switch to ' + (isDark$ | async ? 'light' : 'dark') + ' theme'"
      (click)="toggleTheme()">
      <span class="icon">
        {{ (isDark$ | async) ? 'โ˜€๏ธ' : '๐ŸŒ™' }}
      </span>
    </button>
  `,
  styles: [`
    .theme-toggle {
      position: fixed;
      top: 20px;
      right: 20px;
      width: 50px;
      height: 50px;
      border-radius: 50%;
      background-color: theme-color(bg-secondary);
      border: 2px solid theme-color(border-color);
      cursor: pointer;
      transition: all 0.3s ease;
      z-index: 1000;

      &:hover {
        transform: rotate(20deg) scale(1.1);
      }

      .icon {
        font-size: 24px;
      }
    }
  `]
})
export class ThemeToggleComponent implements OnInit {
  currentTheme$: Observable<Theme>;
  isDark$: Observable<boolean>;

  constructor(private themeService: ThemeService) {
    this.currentTheme$ = this.themeService.getCurrentTheme();
    this.isDark$ = this.currentTheme$.pipe(
      map(theme => theme === 'dark')
    );
  }

  ngOnInit(): void {}

  toggleTheme(): void {
    this.themeService.toggleTheme();
  }
}

Enter fullscreen mode Exit fullscreen mode

Testing Your Theme Implementation (Because We're Professionals)

Nobody talks about testing themes, but broken theme switching in production? That's a bad look.

Unit Testing the Theme Service

// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';
import { DOCUMENT } from '@angular/common';

describe('ThemeService', () => {
  let service: ThemeService;
  let mockDocument: Document;

  beforeEach(() => {
    mockDocument = {
      body: document.createElement('body'),
      dispatchEvent: jasmine.createSpy('dispatchEvent')
    } as any;

    TestBed.configureTestingModule({
      providers: [
        ThemeService,
        { provide: DOCUMENT, useValue: mockDocument }
      ]
    });

    service = TestBed.inject(ThemeService);

    // Clear localStorage before each test
    localStorage.clear();
  });

  it('should initialize with light theme by default', (done) => {
    service.getCurrentTheme().subscribe(theme => {
      expect(theme).toBe('light');
      expect(mockDocument.body.classList.contains('light-theme')).toBe(true);
      done();
    });
  });

  it('should switch themes correctly', () => {
    service.setTheme('dark');

    expect(mockDocument.body.classList.contains('dark-theme')).toBe(true);
    expect(mockDocument.body.classList.contains('light-theme')).toBe(false);
    expect(localStorage.getItem('preferred-theme')).toBe('dark');
  });

  it('should persist theme preference', () => {
    localStorage.setItem('preferred-theme', 'dark');

    // Create new service instance
    const newService = TestBed.inject(ThemeService);

    newService.getCurrentTheme().subscribe(theme => {
      expect(theme).toBe('dark');
    });
  });

  it('should dispatch custom event on theme change', () => {
    service.setTheme('dark');

    expect(mockDocument.dispatchEvent).toHaveBeenCalledWith(
      jasmine.objectContaining({
        detail: 'dark'
      })
    );
  });

  it('should toggle between light and dark themes', (done) => {
    service.setTheme('light');
    service.toggleTheme();

    service.getCurrentTheme().subscribe(theme => {
      expect(theme).toBe('dark');
      done();
    });
  });
});

Enter fullscreen mode Exit fullscreen mode

Testing Theme-Aware Components

// button.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ButtonComponent } from './button.component';
import { ThemeService } from '../services/theme.service';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';

describe('ButtonComponent with Themes', () => {
  let component: ButtonComponent;
  let fixture: ComponentFixture<ButtonComponent>;
  let themeService: ThemeService;
  let buttonEl: DebugElement;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ ButtonComponent ],
      providers: [ ThemeService ]
    });

    fixture = TestBed.createComponent(ButtonComponent);
    component = fixture.componentInstance;
    themeService = TestBed.inject(ThemeService);
    buttonEl = fixture.debugElement.query(By.css('.btn'));
  });

  it('should apply correct styles for light theme', () => {
    themeService.setTheme('light');
    fixture.detectChanges();

    const styles = window.getComputedStyle(buttonEl.nativeElement);
    // Note: Testing CSS variables requires them to be applied to document
    expect(document.body.classList.contains('light-theme')).toBe(true);
  });

  it('should update styles when theme changes', () => {
    themeService.setTheme('light');
    fixture.detectChanges();

    const initialClass = buttonEl.nativeElement.className;

    themeService.setTheme('dark');
    fixture.detectChanges();

    expect(document.body.classList.contains('dark-theme')).toBe(true);
    // Component should maintain its classes
    expect(buttonEl.nativeElement.className).toBe(initialClass);
  });
});

Enter fullscreen mode Exit fullscreen mode

Question for you: How do you handle theme testing in your projects? I've seen some wild approachesโ€”share yours in the comments! ๐Ÿ’ฌ


Bonus Tips: Level Up Your Theme Game ๐Ÿš€

1. Preload Theme to Prevent Flash

Add this to your index.html to prevent the dreaded white flash:

<script>
  // Immediately set theme before Angular loads
  (function() {
    const theme = localStorage.getItem('preferred-theme') || 'light';
    document.body.classList.add(theme + '-theme');
  })();
</script>

Enter fullscreen mode Exit fullscreen mode

2. Custom Theme Builder for Clients

// theme-builder.service.ts
export class ThemeBuilderService {
  generateCustomTheme(brandColors: Partial<ThemeColors>): void {
    const customTheme = { ...this.defaultTheme, ...brandColors };

    // Generate CSS variables dynamically
    const root = document.documentElement;
    Object.entries(customTheme).forEach(([key, value]) => {
      root.style.setProperty(`--theme-${key}`, value);
    });

    // Save to backend or localStorage
    this.saveCustomTheme(customTheme);
  }

  private generateComplementaryColors(primary: string): ThemeColors {
    // Use a library like chroma.js to generate a full palette
    // from a single brand color
    return {
      primary,
      primaryLight: this.lighten(primary, 20),
      primaryDark: this.darken(primary, 20),
      // ... generate rest of palette
    };
  }
}

Enter fullscreen mode Exit fullscreen mode

3. Performance Optimization: Lazy Load Heavy Themes

// For themes with custom fonts or large assets
async loadTheme(themeName: string): Promise<void> {
  const themeModule = await import(`./themes/${themeName}.theme`);
  this.applyTheme(themeModule.default);
}

Enter fullscreen mode Exit fullscreen mode

4. Accessibility Considerations

Always respect user preferences:

// Check for high contrast mode
if (window.matchMedia('(prefers-contrast: high)').matches) {
  this.setTheme('high-contrast');
}

// Check for reduced motion
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  this.disableThemeTransitions();
}

Enter fullscreen mode Exit fullscreen mode

Common Pitfalls (Learn From My Mistakes)

  1. Don't use !important - If you need it, your specificity is wrong
  2. Don't forget print styles - Dark themes look terrible when printed
  3. Test with real content - Lorem ipsum won't show contrast issues
  4. Version your themes - Breaking changes happen, plan for them
  5. Document color decisions - Future you will thank present you

Quick Recap: What We Built Today

โœ… A scalable theme architecture that doesn't suck

โœ… Dynamic theme switching with smooth transitions

โœ… Type-safe theme service with proper testing

โœ… Component integration that actually works

โœ… Unit tests that catch real issues

You now have a theming system that can handle anything from simple dark mode to complex multi-brand applications. No more CSS nightmares, no more find-and-replace marathons.


Your Turn! ๐ŸŽฏ

Alright, I showed you mine, now show me yours!

๐Ÿ’ฌ Drop a comment: What's the weirdest theme requirement you've ever had to implement? I once had to build a theme that changed based on the weather API. Yes, really.

๐Ÿ‘ Found this helpful? Hit that clap buttonโ€”seriously, it takes 2 seconds and helps other devs find this. If this saved you from theme-switching hell, give it the full 50 claps!

๐Ÿ“ฌ Want more Angular architecture tips? I drop a new deep-dive every week. Follow me here or subscribe to my newsletter for early access + bonus code snippets.

๐Ÿš€ Action challenge: Implement this theme system in your current project and share a screenshot of your theme toggle in action. Tag me and I'll feature the best implementations in my next article!

Got questions? Seriously, ask them below. I read every single comment and try to respond within 24 hours. Let's learn from each other!


P.S. - If you're still using inline styles for theming, we need to talk. Drop me a DM, no judgment, just help. ๐Ÿ˜Š


๐ŸŽฏ Your Turn, Devs!

๐Ÿ‘€ Did this article spark new ideas or help solve a real problem?

๐Ÿ’ฌ I'd love to hear about it!

โœ… Are you already using this technique in your Angular or frontend project?

๐Ÿง  Got questions, doubts, or your own twist on the approach?

Drop them in the comments below โ€” letโ€™s learn together!


๐Ÿ™Œ Letโ€™s Grow Together!

If this article added value to your dev journey:

๐Ÿ” Share it with your team, tech friends, or community โ€” you never know who might need it right now.

๐Ÿ“Œ Save it for later and revisit as a quick reference.


๐Ÿš€ Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • ๐Ÿ’ผ LinkedIn โ€” Letโ€™s connect professionally
  • ๐ŸŽฅ Threads โ€” Short-form frontend insights
  • ๐Ÿฆ X (Twitter) โ€” Developer banter + code snippets
  • ๐Ÿ‘ฅ BlueSky โ€” Stay up to date on frontend trends
  • ๐ŸŒŸ GitHub Projects โ€” Explore code in action
  • ๐ŸŒ Website โ€” Everything in one place
  • ๐Ÿ“š Medium Blog โ€” Long-form content and deep-dives
  • ๐Ÿ’ฌ Dev Blog โ€” Free Long-form content and deep-dives
  • โœ‰๏ธ Substack โ€” Weekly frontend stories & curated resources
  • ๐Ÿงฉ Portfolio โ€” Projects, talks, and recognitions

๐ŸŽ‰ If you found this article valuable:

  • Leave a ๐Ÿ‘ Clap
  • Drop a ๐Ÿ’ฌ Comment
  • Hit ๐Ÿ”” Follow for more weekly frontend insights

Letโ€™s build cleaner, faster, and smarter web apps โ€” together.

Stay tuned for more Angular tips, patterns, and performance tricks! ๐Ÿงช๐Ÿง ๐Ÿš€

โœจ Share Your Thoughts To ๐Ÿ“ฃ Set Your Notification Preference

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.