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.