Understanding AnalogJS

Analog is a full-stack meta-framework designed for building Angular-powered applications and websites. Think of it as an Angular-centric alternative to frameworks like Next.JS, Nuxt, or SvelteKit. Its core feature set includes:

  • Built-in support for Vite, Vitest, and Playwright
  • Routing based on the file system
  • Dedicated support for API and server routes
  • Combined SSR and SSG capabilities
  • Compatibility with Angular CLI and Nx workspaces

And that's just scratching the surface.

Kicking Off with Analog

To get going quickly, simply hit the Open Stackblitz button found on the analogjs.org homepage.

For a local setup, the command below will get you started:

npm create analog@latest
Enter fullscreen mode Exit fullscreen mode

This action scaffolds a basic Analog application. Once the dependencies are fetched, launch the development server with:

npm run start
Enter fullscreen mode Exit fullscreen mode

With a fresh Analog project operational, we're ready to integrate dark mode.

Integrating Tailwind into Analog

One of the best parts is that Analog & Vite offer native PostCSS support. This means we can largely stick to Tailwind's official Using PostCSS guide.

The first step is installing the necessary packages.

npm install -D tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

Two files need to be created in the root directory:

1. postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
Enter fullscreen mode Exit fullscreen mode

This setup instructs Vite to activate PostCSS and execute the tailwindcss and autoprefixer plugins.

2. tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{html,ts}'],
  darkMode: 'class',
  theme: {
    extend: {},
  },
  plugins: [],
};
Enter fullscreen mode Exit fullscreen mode

This configuration guarantees that Tailwind scans for all classes within the source folder.
This file deviates from the standard Tailwind guide in two key ways:

  1. The content property's file extension is changed to ts.
  2. The darkMode property is included and assigned the value class. These modifications make sure your Angular files are scanned, and allow us to switch Tailwind's dark mode classes on and off by toggling the dark class on the html element.

3. Add default styles to styles.css

The last setup step is to inject the Tailwind directives into our primary CSS file.

/*
  Allow percentage-based heights in the application
*/
html,
body {
  height: 100%;
}
/*
  Remove built-in form typography styles
*/
input,
button,
textarea,
select {
  font: inherit;
}
/*
  Avoid text overflows
*/
p,
h1,
h2,
h3,
h4,
h5,
h6 {
  overflow-wrap: break-word;
}
/*
  Create a root stacking context
*/
app-root {
  isolation: isolate;
}

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

You may have noticed that I've also incorporated a few extra CSS resets, inspired by an excellent blog post from Jon Comeau.

Inserting an initialization script into index.html

With Tailwind configured, we can now address the first step of dark mode integration.

We start by placing a <script> block in index.html. This blocking script serves a critical purpose: it reads the saved theme from localStorage and adds the dark class to the <html> element immediately upon page load. This happens before our Angular app boots and, crucially, before any pixels are painted to the screen. Consequently, the browser can correctly render the dark variant of Tailwind classes from the very first frame.

For a deeper dive into this technique, I recommend checking out this section of another fantastic write-up by Jon Comeau.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>MyApp</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="/src/favicon.ico" />
    <link rel="stylesheet" href="/src/styles.css" />
    <script>
      if (
        // check if user had saved dark as their 
        // theme when accessing page before
        localStorage.theme === 'dark' ||
        // or user's requesting dark color 
        // scheme through operating system
        (!('theme' in localStorage) &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      ) {
        // then if we have access to the document and the element
        // we add the dark class to the html element and
        // store the dark value in the localStorage
        if (document && document.documentElement) {
          document.documentElement.classList.add('dark');
          localStorage.setItem('theme', 'dark');
        }
      } else {
        // else if we have access to the document and the element
        // we remove the dark class to the html element and 
        // store the value light in the localStorage
        if (document && document.documentElement) {
          document.documentElement.classList.remove('dark');
          localStorage.setItem('theme', 'light');
        }
      }
    </script>
  </head>
  <body>
    <app-root></app-root>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Here’s a breakdown of the logic inside the script:

if (
        // check if user had saved dark as their 
        // theme when accessing page before
        localStorage.theme === 'dark' ||
        // or user's requesting dark color 
        // scheme through operating system
        (!('theme' in localStorage) &&
          window.matchMedia('(prefers-color-scheme: dark)').matches)
      ) {
        // then if we have access to the document and the element
        // we add the dark class to the html element and
        // store the dark value in the localStorage
        if (document && document.documentElement) {
          document.documentElement.classList.add('dark');
          localStorage.setItem('theme', 'dark');
        }
      } else {
        // else if we have access to the document and the element
        // we remove the dark class to the html element and 
        // store the value light in the localStorage
        if (document && document.documentElement) {
          document.documentElement.classList.remove('dark');
          localStorage.setItem('theme', 'light');
        }
      }
Enter fullscreen mode Exit fullscreen mode
  • The script checks two things: does the user's localStorage contain a theme property set to dark, or does the operating system have a dark color scheme preference?
  • If either condition is true, and the document's html element is accessible, we apply the dark class and save dark as the theme value in localStorage.
  • If neither condition is met, and the document's html element is accessible, we remove the dark class and save light as the theme value in localStorage.

See the complete file's source code here.

Our page now loads Tailwind styles and applies the initial color scheme correctly before anything else happens.

Note

This tutorial leverages Analog, Vite, and Tailwind. If you're working on standard Angular projects powered by Webpack, the underlying principles stay the same. Tailwind simplifies the dark mode process significantly. Conversely, you could achieve the same outcome with raw CSS or a preprocessor like SCSS.

Building the ThemeService

Next, we'll give users the ability to manually switch between themes. For that, we're creating a singleton Angular service responsible for five tasks:

  1. Initializing its state from localStorage on startup.
  2. Maintaining an in-memory representation of the current theme.
  3. Providing a public API to flip the theme and persist the change to localStorage.
  4. Managing the dark class on the root html element.
  5. Publishing the active theme as an Observable for the rest of the app.

We'll place this service in /src/libs/theme/theme.service.ts with the following implementation:

@Injectable({
  providedIn: 'root',
})
export class ThemeService implements OnDestroy {
  // A. Setting up our dependencies
  // A.1 since we will access localStorage with AnalogJS
  // (which can be used for server side rendering)
  // we will use the PLATFORM_ID to see if we are executing in the browser and
  // it is available
  private _platformId = inject(PLATFORM_ID);
  // A.2 we use Angular's renderer to add/remove the dark class from the html element
  private _renderer = inject(RendererFactory2).createRenderer(null, null);
  // A.3 we use Angular's DOCUMENT injection token to avoid directly accessing the document object
  private _document = inject(DOCUMENT);

  // B. Initializing our in memory theme store
  // B.1 we want to give every subscriber the current value of our theme
  // even if they subscribe after the first value was emitted
  private _theme$ = new ReplaySubject<'light' | 'dark'>(1);
  // B.2 we expose the current theme so our app can access it and e.g. show
  // a different icon for the button to toggle it
  public theme$ = this._theme$.asObservable();
  // B.3 this emits when the service is destroyed and used to clean up subscriptions
  private _destroyed$ = new Subject<void>();

  // C. Sync and listen to theme changes on service creation
  constructor() {
    // we check the current value in the localStorage to see what theme was set
    // by the code in the index.html file and load that into our _theme$ replaysubject
    this.syncThemeFromLocalStorage();
    // we also immediately subscribe to our theme$ variable and add/remove
    // the dark class from the html element
    this.toggleClassOnThemeChanges();
  }

  // C.1 sync with the theme set in the localStorage by our index.html script tag
  private syncThemeFromLocalStorage(): void {
    // if we are in the browser we know we have access to localstorage
    if (isPlatformBrowser(this._platformId)) {
      // we load the appropriate value from the localStorage into our _theme$ replaysubject
      this._theme$.next(
        localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
      );
    }
  }
  // C.2 Subscribe to theme changes until the service is destroyed
  // and add/remove class from html element
  private toggleClassOnThemeChanges(): void {
    // until our service is destroyed we subscribe to all changes in the theme$ variable
    this.theme$.pipe(takeUntil(this._destroyed$)).subscribe((theme) => {
      // if it is dark we add the dark class to the html element
      if (theme === 'dark') {
        this._renderer.addClass(this._document.documentElement, 'dark');
      } else {
        // else if is added already, we remove it
        if (this._document.documentElement.className.includes('dark')) {
          this._renderer.removeClass(this._document.documentElement, 'dark');
        }
      }
    });
  }

  // D. Expose a public function that allows us to change the theme from anywhere in our application
  public toggleDarkMode(): void {
    const newTheme =
      localStorage.getItem('theme') === 'dark' ? 'light' : 'dark';
    localStorage.setItem('theme', newTheme);
    this._theme$.next(newTheme);
  }

  // E. Clean up our subscriptions when the service gets destroyed
  public ngOnDestroy(): void {
    this._destroyed$.next();
    this._destroyed$.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

Here's a detailed walkthrough of the code:

A. Setting up the dependencies
A.1 Since Analog supports server-side rendering, our code might execute outside the browser. We inject PLATFORM_ID to verify we're in a browser environment where localStorage is available.
A.2 We inject Angular's RendererFactory and call createRenderer with null arguments to obtain the default renderer. This renderer handles adding and removing the dark class on the document's html element.
A.3 Through Angular's DOCUMENT injection token, we gain access to the document without directly touching the browser's global object — a must for SSR compatibility.

B. Initializing the in-memory theme store
B.1 A ReplaySubject with a buffer size of 1 stores the current theme (light or dark). This ensures late subscribers immediately receive the most recent value.
B.2 We expose the subject as a read-only Observable to external consumers.
B.3 A _destroyed$ Subject is declared to manage subscription cleanup when the service is destroyed.

Now, let's trace what occurs during service instantiation:

C. In the constructor, we synchronize the theme from localStorage and subscribe to theme$ changes to update the dark class on the html element.
C.1 The correct theme should already be in localStorage, placed there by the <script> in index.html. We check if we're in the browser, and if so, load the stored value into _theme$.
C.2 Still within the constructor, we subscribe to theme$ until _destroyed$ emits. For a dark theme, the dark class is added to the html element via the renderer. For light, the class is removed if present.

D. The public toggle method reads the current value from localStorage before determining the new theme. This new value is then pushed into _theme$, keeping both the subject and localStorage in lockstep.

E. We set up subscription termination via _destroyed$. As this is a root-provided singleton, it would only be destroyed with the entire application — but explicit cleanup is still a best practice.

The live implementation is available here.

Important: For Angular to create a singleton, all import paths must match exactly. This means avoiding relative paths for the ThemeService. To enable Vite's absolute path resolution, we add the following configuration to vite.config.js:

export default defineConfig(({ mode }) => ({
...
  resolve: {
    ...
    alias: {
      src: path.resolve(__dirname, './src'),
    },
  },
...
}));
Enter fullscreen mode Exit fullscreen mode

With this in place, we can import from anywhere within src. For example, our (home).ts route will use:
import { ThemeService } from 'src/libs/theme/theme.service';
instead of:
import { ThemeService } from '../../libs/theme/theme.service';
Relative imports can lead to multiple service instances, causing perplexing issues where components observe different theme states.

It took me a while to realize why my ThemeService behaved inconsistently across components...

Integrating the ThemeService into the App

With the service ready, we'll put it to work. We're adding a theme-toggle button to the shared header, making it accessible app-wide. The HomeComponent will also display the current theme, proving it's globally available. Tailwind's dark mode utilities will handle the styling.

Adding a Toggle to AppComponent

We'll modify /src/app/app.component.ts as shown:

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [AsyncPipe, RouterOutlet],
  host: {
    class:
      'block h-full bg-zinc-50 text-zinc-900 dark:text-zinc-50 dark:bg-zinc-900',
  },
  template: ` 
  <header class="p-4">
  <button (click)="toggleTheme()">Toggle theme</button>
  </header>
  <router-outlet></router-outlet> `,
})
export class AppComponent {
  private _themeService = inject(ThemeService);

  public toggleTheme(): void {
    this._themeService.toggleDarkMode();
  }
}
Enter fullscreen mode Exit fullscreen mode

The component code is accessible here.

This component now serves as the application's entry point, with the theme toggle button placed in its header template.

For the sleek aesthetic, we've applied Tailwind classes on the host that adapt to the current theme. Classes prefixed with dark are activated when the dark class is attached to the root html element. Specifically:
bg-zinc-50 text-zinc-900 dark:text-zinc-50 dark:bg-zinc-900
This gives a light zinc background with dark text by default, inverting to a dark background with light text in dark mode.

Inside the component, we've wired the toggleTheme method to the service's toggleDarkMode. The service is obtained via Angular's inject function.

Showing the Theme on the Home Page

To complete the picture, we'll surface the active theme on the homepage.

Analog leverages Angular's Router to provide filesystem-based routing. The folder and file structure determines the route hierarchy. For a deeper dive, consult Analog's routing documentation.

We'll keep this straightforward. An index route is created via a component in /src/app/routes/(home).ts:

@Component({
  selector: 'app-home',
  standalone: true,
  imports: [AsyncPipe],
  host: {
    class: 'block'
  },
  template: `
  <div class="flex p-12 gap-8 items-center justify-center">
  <img class="h-20 w-20" src="/analog.svg"/>
  <div class='w-[1px] h-14 dark:bg-zinc-50 bg-zinc-900'></div>
  <img class="h-20 w-20" src="/tailwind.svg"/>
  </div>
  <h2 class="text-2xl text-center">Analog + Tailwind: Darkmode</h2>
  <p class="mt-2 text-center">Current theme: {{theme$ | async}}</p>
  `,
})
export default class HomeComponent {
  private _themeService = inject(ThemeService);
  public theme$ = this._themeService.theme$;
}
Enter fullscreen mode Exit fullscreen mode

The HomeComponent source is found here.

Once more, we employ Tailwind classes, including some dark variants, for thematic styling. The ThemeService is injected, and its theme$ observable is exposed to the template and displayed via the AsyncPipe.

Thanks to our absolute import configuration (import { ThemeService } from 'src/libs/theme/theme.service';), the theme$ value will correctly reflect light or dark based on the current theme, confirming we're working with a single, shared instance.

Explore the complete, working example here!

Conclusion

That's it! Your Analog application now has full dark mode support. It defaults to the user's system preference, prevents any flash of the wrong theme on load, and offers a global mechanism to read and change the theme from any component.

Questions, feedback, or topics you'd like to see next? Have you experimented with Analog, or are you curious about meta-frameworks and their advantages? I'd love to hear from you — feel free to drop a comment or send a message.

If you found this useful, please consider sharing it. And for more content, follow me on Twitter or check out my work on Github.