I have crafted one from scratch based on specific functional requirements and technical requirements, conforming to my design principles for UI, UX and Developer Experience.
Developer Experience expected:
- Rich features with small API surface.
- Flexibility.
- Portability & Reusability.
If you just want to use this loader right away, please check "Web Theme Loader with Comprehensive Features and Minimum API Surface"
This article is the first in a series of articles comparing hand‑crafted code and code generated by AI agents on a non‑trivial topic.
Requirements
User Story
As a web app user, I want to choose from multiple available themes — sometimes light, other times dark.
Work Order
Develop a TypeScript‑based, framework‑agnostic API that exposes reusable helpers and abstractions for implementing theme pickers in web applications. The API should remain independent of any specific framework while optionally offering seamless integration points for Angular.
Functional Requirements
- Support both light and dark themes.
- Support more than two themes.
- Support commonly used prebuilt themes, optionally combined with an app‑specific color stylesheet such as
colors.css, with an optional dark‑mode variant likecolors-dark.css. - Support dynamic switching between themes at runtime.
- When the same web app/site is opened in another browser tab, the explicitly selected theme should be preserved and applied.
Technical Requirements
- Reusable across multiple applications.
- Minimal API surface to ensure easy configuration and customization.
- Neutral with respect to specific UI design choices.
- Must be efficient and avoid visual flicker during startup and theme switching.
- Usable in both SPA and PWA.
- Fully functional in PWAs, offline usage, and intranet environments.
- Configurable after build, bundling, and deployment. For example, an IT admin should be able to change the number and order of available themes, and modify app‑specific color files.
- Themes may be hosted locally with the app code or on a CDN.
- Selecting a theme that is already loaded should not trigger a reload of the theme.
- Core theme management must be separated from the theme‑picker UI.
Examples in the real world:
If you find your requirements match mine, please read on.
The following sourcecode is crafted in TypeScript for Angular SPA, and it should be reusable in other Web apps or sites crafted in JavaScript, with near zero modification.
## Theme Loader
themeLoader.ts (full sourcecode)
import { ThemeConfigConstants } from "./themeDef"; //just for typed
/**
* Helper class to load default theme or selected theme among themes defined in app startup settings.
* index.html should not have the theme css link during design time.
* In addition to the main theme which could be one the prebuilt themes reusable across apps, like one of those of Angular Material,
* the app may optionally has its own app css file for colors.
*/
export class ThemeLoader {
private static readonly settings = ThemeConfigConstants.themeLoaderSettings;
/**
* selected theme file name saved in localStorage.
*/
static get selectedTheme(): string | null {
return this.settings ? localStorage.getItem(this.settings.storageKey) : null;
};
private static set selectedTheme(v: string) {
if (this.settings) {
localStorage.setItem(this.settings.storageKey, v);
}
};
/**
* Load default or previously selected theme during app startup, typically used before calling `bootstrapApplication()`.
*/
static init(){
this.loadTheme(this.selectedTheme);
}
/**
* Load theme during operation through `ThemeLoader.loadTheme(themeDicKey);`.
* @param picked one of the prebuilt themes, typically used with the app's theme picker.
*/
static loadTheme(picked: string | null) {
if (!ThemeConfigConstants.themesDic || !this.settings || Object.keys(ThemeConfigConstants.themesDic).length === 0) {
console.error('AppConfigConstants need to have themesDic with at least 1 item, and themeKeys.');
return;
}
let themeLink = document.getElementById(this.settings.themeLinkId) as HTMLLinkElement;
if (themeLink) { // app has been loaded in the browser page/tab.
const currentTheme = themeLink.href.substring(themeLink.href.lastIndexOf('/') + 1);
const notToLoad = picked == currentTheme;
if (notToLoad) {
return;
}
const themeValue = ThemeConfigConstants.themesDic[picked!];
if (!themeValue) {
return;
}
themeLink.href = picked!;
this.selectedTheme = picked!;
console.info(`theme altered to ${picked}.`);
if (this.settings.appColorsLinkId) {
let appColorsLink = document.getElementById(this.settings.appColorsLinkId) as HTMLLinkElement;
if (appColorsLink) {
if (themeValue.dark != null && this.settings.colorsDarkCss && this.settings.colorsCss) {
const customFile = themeValue.dark ? this.settings.colorsDarkCss : this.settings.colorsCss;
appColorsLink.href = (this.settings.appColorsDir ?? '') + customFile;
} else if (this.settings.colorsCss) {
appColorsLink.href = (this.settings.appColorsDir ?? '') + this.settings.colorsCss;
}
}
}
} else { // when app is loaded for the first time, then create
themeLink = document.createElement('link');
themeLink.id = this.settings.themeLinkId;
themeLink.rel = 'stylesheet';
const themeDicKey = picked ?? Object.keys(ThemeConfigConstants.themesDic!)[0];
themeLink.href = themeDicKey;
document.head.appendChild(themeLink);
this.selectedTheme = themeDicKey;
console.info(`Initially loaded theme ${themeDicKey}`);
if (this.settings.appColorsLinkId) {
const appColorsLink = document.createElement('link');
appColorsLink.id = this.settings.appColorsLinkId;
appColorsLink.rel = 'stylesheet';
const themeValue = ThemeConfigConstants.themesDic[themeDicKey];
if (themeValue.dark != null && this.settings.colorsDarkCss && this.settings.colorsCss) {
const customFile = themeValue.dark ? this.settings.colorsDarkCss : this.settings.colorsCss;
appColorsLink.href = (this.settings.appColorsDir ?? '') + customFile;
} else if (this.settings.colorsCss) {
appColorsLink.href = (this.settings.appColorsDir ?? '') + this.settings.colorsCss;
}
if (appColorsLink.href) {
document.head.appendChild(appColorsLink);
console.info(`appColors ${appColorsLink} loaded.`)
} else {
console.warn(`With appColorsLinkId defined, dark&colorsCss&colorDarkCss or colorsCss should be defined.`)
}
}
}
}
}
Configuration
Typically an Web app with JavaScript has some settings that should be loaded at the very beginning synchronously.
Data schema (full sourcecode):
export interface ThemeValue {
/** Display name used in the UI like menu or dropdown */
display: string;
/** Dark them or not. Optionally to tell which optional app level colors CSS to use, if some app level colors need to adapt the light or dark theme. */
dark?: boolean;
}
export interface ThemeDef extends ThemeValue {
/** Relative path or URL to CDN */
filePath: string;
}
export interface ThemesDic {
[filePath: string]: ThemeValue
}
export interface ThemeLoaderSettings {
/**
* The key of themeDic to store the selected theme in local storage of browser. Each app or site must have a unique key to avoid conflict with other apps or sites.
*/
storageKey: string;
/**
* The id of the link element in index.html for loading the theme CSS file dynamically during app startup and operation.
*/
themeLinkId: string;
/**
* Optionally the app may has an app level colors CSS declaring colors neutral to the light or dark theme, in addition to a prebuilt theme reused across apps.
* If some colors need to adapt the light or dark theme, having those colors defined in colorsCss and colorsDarkCss is convenient for SDLC, since you can
* use tools to flip colors to dark or light.
*/
appColorsLinkId?: string;
/**
* If undefined or null, app colors css is in root.
* Effected only when appColorsLinkId is defined.
*/
appColorsDir?: string;
/**
* Optionally the app may has an app level colors CSS declaring colors adapting to the light theme.
* If the app uses only light or dark theme, for example ThemeValue.dark is not defined, this alone is enough, not needing colorsDarkCss.
*/
colorsCss?: string;
/**
* Optionally the app may has an app level colors CSS declaring colors adapting to the dark theme.
* If the app uses only light or dark theme, there's no need to declare this.
*/
colorsDarkCss?: string;
}
interface Theme_Config {
themesDic?: ThemesDic,
themeLoaderSettings?: ThemeLoaderSettings
}
declare const THEME_CONFIG: Theme_Config
export const ThemeConfigConstants: Theme_Config = {
...(typeof THEME_CONFIG === 'undefined' ? {} : THEME_CONFIG),
}
siteconfig.js (full sourcecode):
const THEME_CONFIG = {
themesDic: {
"assets/themes/rose-red.css":{display: "Roes & Red", dark:false},
"assets/themes/azure-blue.css":{display: "Azure & Blue", dark:false},
"assets/themes/magenta-violet.css":{display: "Magenta & Violet", dark:true},
"assets/themes/cyan-orange.css":{display: "Cyan & Orange", dark:true}
},
themeLoaderSettings: {
storageKey: 'app.theme',
themeLinkId: 'theme',
appColorsDir: 'conf/',
appColorsLinkId: 'app-colors',
colorsCss: 'colors.css',
colorsDarkCss: 'colors-dark.css'
}
}
index.html (full sourcecode):
...
<script src="conf/siteconfig.js"></script>
...
Hints:
- Theme filename can be URL to CDN.
- When the Website or app is launched for the first time, the top one in themesDic is the default.
- Having the config as js file give you or your customers to alter the config after build or after deployment.
- Depending on your app design, you may hardcode the details of
THEME_CONFIGin code without using a JS file.
Startup
To ensure Angular runtime to utilize the theme as early as possible before rendering any component, ThemeLoader must be called before bootstrap:
main.ts
ThemeLoader.loadTheme(ThemeLoader.selectedTheme);
bootstrapApplication(AppComponent, appConfig);
html
And you may do the same for React apps and alike.
UI for Switching Theme
Typically the UI of switching between themes is a dropdown implemented using something like MatMenu or MatSelect, while there are Websites for graphic designers coming with complex runtime styles and theme selection UI, like what in PrimeVue. However, I would doubt any business app or consumer app would favor such powerful complexity.
HTML with MatSelect:
<mat-form-field>
<mat-label i18n>Themes</mat-label>
<mat-select #themeSelect (selectionChange)="themeSelectionChang($event)" [value]="currentTheme">
@for (item of themes; track $index) {
<mat-option [value]="item.fileName">{{item.name}}</mat-option>
}
</mat-select>
</mat-form-field>
Code behind (full codes):
themes?: ThemeDef[];
currentTheme: string | null;
...
this.themes = AppConfigConstants.themesDic ? Object.keys(AppConfigConstants.themesDic).map(k => {
const c = AppConfigConstants.themesDic![k];
const obj: ThemeDef = {
name: c.name,
fileName: k,
dark: c.dark
};
return obj;
}) : undefined;
...
themeSelectionChang(e: MatSelectChange) {
ThemeLoader.loadTheme(e.value);
}
Summary
The API exposes 3 contracts:
-
static init()of themeLoader to be called during app startup. -
static loadTheme(picked: string | null, appColorsDir?: string | null)to be called when the app user picks one from available themes. -
static get selectedTheme(): string | nullof themeLoader to give the URL of the selected theme, so GUI may display which theme is in-use. - JavaScript constant SITE_CONFIG that contains a theme dictionary and app specific theme settings.
Web Sites and Apps that Use This ThemeLoader
- Angular Material Components Extension
- JsonToTable
- Personal Blog
- Angular Heroes, and Sourcecode
- React Heroes and Sourcecode
Alternative Implementation by Angular Material Documentation
After Angular Material Components v12, the documentation site has been merged into the components' repository.
Please check https://github.com/angular/components/blob/main/docs/src/app/shared/theme-picker/ and https://github.com/angular/material.angular.io/blob/main/src/app/shared/style-manager/ .
The design basically conforms to the "Requirements" above, though more complex and comprehensive in the contexts of the documentation site, and within its business scope. Overall, decent and elegant enough.
And likely, the design and the implementation have inspired many LLMs based AI code generators.
Alternative Designs by AI Code Agents
Using the requirements above as prompt, I asked Windows Copilot, then asked M365 Copilot of another account, and the Claude.AI etc. to generate sourcecode.
- Web Theme Loader Generated by Windows Copilot
- Web Theme Loader Generated by M365 Copilot
- Web Theme Loader Generated by ClaudeAi
- Google AI Studio
My Take on AI Code Generators
For almost a year, since early 2025, I have been using Windows Copilot and M365 Copilot to help my daily software development works, mostly trivial works, and occasionally heavy scaffolding, covering these areas:
- Simple data transformation, such as JSON data to CSV.
- Common algorithms.
- Common code snippets.
- Craft CSS, or create dark theme based on existing CSS.
- Sample codes regarding some details of frameworks and libraries that I am not familiar with or forget.
- Scaffold codes for synchronizing data sets, for example, sync the customers of ErpNext to contacts of QuickBook.
- Migrating legacy React app to newer React lib.
- ...
I feel pleased, relax and productive with such junior programmers helping me, releasing me from trivial and repetitive technical details.
The attempts above asking AI to generate a theme loader is to have more hand-on experience in using AI in other areas. I will be writing a series of articles about how AI could help senior developers, the inherent shortfalls of AI code generators and why such short falls exist.
Top comments (0)