Summary

In this in-depth technical article, we explore how to implement a theme selector (light/dark) without the unpleasant flickering effect that occurs during page load. We analyze in detail the browser rendering cycle, the root causes of FOUC (Flash of Unstyled Content), and propose a robust solution based on synchronous preloading in the`<head>`. This technique ensures a smooth and professional user experience.

The Problem: That Cursed Theme Flicker

A User Experience Nightmare

Imagine the scene: you have spent hours designing a beautiful dark theme for your web application. The colors are perfectly balanced, the contrast is optimal, and your users love this option. But there is an embarrassing problem: with every page reload, for a fraction of a second, the default light theme is displayed before the dark theme takes over.

This visual "flash," although brief (sometimes less than 100ms), is immediately perceptible to the human eye and creates an unpleasant experience. For users who have chosen the dark theme for visual comfort or accessibility reasons, this flicker can even be painful, particularly in a low-light environment.

FOUC: An Old Web Problem

This phenomenon is a variant of what is called "Flash of Unstyled Content" (FOUC), a classic web development problem dating back to the early days of CSS. FOUC occurs when the browser temporarily displays HTML content without its CSS styles applied, creating a flash of unstyled content.

In our specific case, we are not talking about completely unstyled content, but rather aFlash of Wrong Theme(FOWT) - the content is styled, but with the wrong theme. This is particularly frustrating because it shows that our application "forgets" the user’s preference on every page load.

Impact on Quality Perception

This problem, although technical, has significant repercussions on the perception of your application’s quality:

Lack of polish: The flicker gives the impression of an unfinished or poorly optimized application. Users often associate these small visual flaws with a general lack of professionalism.

Break in consistency: The application seems to "forget" user preferences, creating a feeling of desynchronization between the interface and expectations.

Visual fatigue: For users sensitive to light or suffering from migraines, this bright flash can be more than just an aesthetic nuisance.

Perceived performance: Ironically, even if your site loads quickly, this flicker can give the impression of a slow or unresponsive application.

Technical Analysis of the Problem

To understand how to solve this problem, one must first understand why it occurs. The flicker happens because of a time lag between three critical events in a web page’s lifecycle:

  1. Initial HTML parsing: The browser reads and analyzes your page structure

  2. Application of CSS styles: The browser applies CSS rules and calculates the visual rendering

  3. JavaScript execution: Your code that changes the theme executes

The problem occurs when event #3 (JavaScript execution) arrives after the browser has already started or finished event #2 (application of styles). At that point, the browser has already made a decision on which theme to display, and your code arrives too late to influence it before the first render.

Why a Standard Script Isn’t Enough?

The Intuitive but Ineffective Approach

The most natural approach for a developer would be to place a script at the end of our`<body>`that checks the user’s preferred theme and applies it. This approach follows traditional web best practices which recommend loading scripts at the end of the page so as not to block rendering.

// À la fin de <body> - L'APPROCHE INSUFFISANTE
document.addEventListener('DOMContentLoaded', () => {
  const theme = localStorage.getItem('preferred-theme');
  if (theme === 'dark') {
    document.documentElement.setAttribute('data-bs-theme', 'dark');
  }
});

This approach seems logical at first glance. We wait for the DOM to be ready, then we apply the theme. Simple, right? Unfortunately, this simplicity hides a fundamental flaw related to the timing of the browser’s rendering cycle.

Understanding the DOMContentLoaded Event

The`DOMContentLoaded`event triggers when the initial HTML document has been completely loaded and parsed by the browser,without waitingfor the completion of the loading of stylesheets, images, and sub-frames. This is an important point to understand.

Here is the typical sequence of events:

  1. The browser starts downloading the HTML

  2. It parses the HTML as it is received

  3. It discovers the`<link>`tags for CSS and starts downloading them

  4. It discovers the`<script>`tags and executes them (depending on their type and attributes)

  5. It builds the DOM (Document Object Model)

  6. The DOMContentLoaded event triggers

  7. It continues to apply styles and perform the layout

  8. The first paint (display) occurs

  9. The`load`event triggers when all resources are loaded

The problem? Between step 6 (DOMContentLoaded) and step 8 (first paint), the browser has already made decisions on how to display the page. If your theme-change script executes at step 6, it is already too late to avoid a first display with the default styles.

The Render Blocking Problem

In reality, the timing is even more complex. Modern browsers use sophisticated optimization techniques to improve perceived performance. They try to perform the first paint (First Contentful Paint) as quickly as possible so that the user sees something on the screen.

CSS is "render-blocking" by default, which means the browser waits until it has downloaded and parsed the stylesheets before performing the first paint. This makes sense: we don’t want to display unstyled content.

But here is the trap: when the browser applies these CSS styles for the first time, it does so based on the current state of the DOM. If the`data-bs-theme`attribute is not yet defined on the`<html>`tag, the browser will apply the default styles (usually the light theme).

Then, when your script executes and changes this attribute, the browser must:

  1. Recalculate all styles affected by this change

  2. Redo the layout if necessary

  3. Repaint the affected elements

This process of recalculation and repainting is what causes the visible flicker.

Visualizing the Problem

To better understand this problematic sequence, let’s examine a detailed sequence diagram:

Diagramme de séquence du chargement incorrect

This diagram clearly illustrates the problem: the first paint occurs before our script has had the chance to define the correct theme. The subsequent repaint creates the visible flicker.

Ineffective Solution Attempts

Several approaches have been attempted to solve this problem, but most have their own drawbacks:

Approach 1: Hiding content until loading

body {
  opacity: 0;
  transition: opacity 0.3s;
}

body.loaded {
  opacity: 1;
}

This approach hides all content until the JavaScript has defined the correct theme. The problem? This artificially delays the display of content, giving the impression of a slower site. Furthermore, if JavaScript is disabled, the user sees nothing at all!

Approach 2: Using a loader/spinner

Similar to approach 1, but with a loading spinner. This masks the problem but does not improve actual performance and adds an unnecessary perceived delay.

Approach 3: Defaulting to dark theme

Some developers define the dark theme as the default in CSS. This avoids the flicker for dark theme users, but creates the reverse problem for light theme users!

None of these approaches are satisfactory because they treat the symptom rather than the root cause of the problem.

The Real Solution: Acting Earlier

The key to solving this problem is realizing that we must define the`data-bs-theme` beforethe browser begins applying the CSS styles. This means our script must execute earlier in the page’s lifecycle, and that is exactly what we will explore in the next section.

The Solution: Early Loading

The Fundamental Principle

The elegant solution to our flickering problem rests on a simple but powerful principle:synchronize the application state with the browser’s rendering process. Instead of waiting for the page to be loaded to define the theme, we must define itduringloading, even before the CSS styles are applied.

This approach is called "Early Loading" or "Synchronous Preloading" in web development jargon. The idea is to execute our theme detection logic as early as possible in the page lifecycle, ideally in the`<head>`tag, even before the browser starts downloading CSS files.

Why the <head> is the Ideal Place

Le `<head>`of an HTML document is processed sequentially by the browser, from top to bottom. Each element is processed in the order it appears. This characteristic is crucial for our solution.

When the browser encounters a`<script>`tag in the`<head>`without the`async` ou defer, il :

  1. Interrupts the HTML parsing

  2. Downloads the script(if external) or reads it (if inline)

  3. Executes the script immediately

  4. Resumes HTML parsing

This behavior, often considered a performance problem (hence the usual recommendation to place scripts at the end of the page), becomes our ally in this specific case. By placing our theme detection script at the beginning of the`<head>`, we guarantee that it executes before the browser encounters the`<link>`tags of our stylesheets.

Three-Layer Solution Architecture

Our complete solution consists of three interdependent layers, each playing a specific role:

Layer 1: Persistence (localStorage) This layer is responsible for saving and retrieving the user’s choice between sessions.

Layer 2: Early Synchronization (Inline script in <head>) This layer synchronizes the application state with the DOM before the initial render.

Layer 3: Reactive Styles (CSS with attribute selectors) This layer defines the visual styles based on the state defined by layer 2.

Let’s now explore each layer in detail.

Step 1: Saving the User’s Choice

localStorage: Your Persistent Memory

Le `localStorage`is a Web Storage API that allows storing key-value pairs in the browser persistently. Unlike cookies,`localStorage`data:

  • Is never sent to the server automatically

  • Has a larger storage capacity (generally 5-10MB)

  • Has no expiration date (persistent until explicit deletion)

  • Is limited to the protocol and domain (Same-Origin Policy)

For our use case,`localStorage`is perfect because:

  1. We don’t need to share this information with the server

  2. We want the preference to persist indefinitely

  3. The required storage size is minimal (a few bytes)

Implementing Theme Saving

Here is how we save the user’s choice when they change the theme:

// Fonction complète pour changer le thème
function setTheme(newTheme) {
  // Validation de l'entrée
  if (!['light', 'dark', 'auto'].includes(newTheme)) {
    console.error('Thème invalide:', newTheme);
    return;
  }

  try {
    // Sauvegarde dans localStorage
    localStorage.setItem('preferred-theme', newTheme);

    // Application immédiate dans le DOM
    document.documentElement.setAttribute('data-bs-theme', newTheme);

    // Dispatch d'un événement personnalisé pour notifier d'autres composants
    window.dispatchEvent(new CustomEvent('theme-changed', {
      detail: { theme: newTheme }
    }));

    console.log('Thème changé:', newTheme);
  } catch (error) {
    console.error('Erreur lors de la sauvegarde du thème:', error);
    // Fallback : on applique quand même le thème visuellement
    document.documentElement.setAttribute('data-bs-theme', newTheme);
  }
}

// Exemple d'utilisation avec un bouton
document.getElementById('theme-toggle').addEventListener('click', () => {
  const currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light';
  const newTheme = currentTheme === 'light' ? 'dark' : 'light';
  setTheme(newTheme);
});

Error Case Management

It is crucial to handle cases where`localStorage`is unavailable or inaccessible. Several scenarios can prevent access to`localStorage`:

Strict private browsing: Safari in private browsing mode throws a`QuotaExceededError`exception when attempting to write to`localStorage`.

Privacy settings: Some browsers or privacy extensions may block access to`localStorage`.

Domain limitations : Le `localStorage`is not accessible on the`file://`protocol in some browsers.

Saturated storage space: Although rare, storage space can be completely full.

That is why our code uses a`try…​catch`block to handle these cases gracefully, continuing to offer the theme-change functionality even if persistence is unavailable.

Advanced Persistence Strategies

For more sophisticated applications, you may consider additional strategies:

Server synchronization (optional)

async function setTheme(newTheme) {
  // Sauvegarde locale immédiate
  localStorage.setItem('preferred-theme', newTheme);
  document.documentElement.setAttribute('data-bs-theme', newTheme);

  // Synchronisation serveur en arrière-plan (si l'utilisateur est connecté)
  if (userIsAuthenticated()) {
    try {
      await fetch('/api/user/preferences', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ theme: newTheme })
      });
    } catch (error) {
      console.warn('Échec de la synchronisation serveur:', error);
      // L'échec n'est pas critique car la préférence est déjà sauvegardée localement
    }
  }
}

This approach allows synchronizing preferences across devices for logged-in users, while maintaining immediate local responsiveness.

Step 2: The Preloading Script in the <head>

The Heart of the Solution

This is where the magic truly happens. We will place a smallinlinescript directly in our`<head>`, before all our stylesheet`<link>`tags. This script is intentionally minimalist, self-contained, and designed to execute as quickly as possible.

<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Mon Site Incroyable</title>

    <!-- ==========================================
         NOTRE SCRIPT MAGIQUE DE PRÉ-CHARGEMENT
         Ce script DOIT être le premier élément
         dans le <head> après les meta tags
         ========================================== -->
    <script>
      // IIFE pour ne pas polluer le scope global
      (function() {
        'use strict';

        try {
          // Lecture de la préférence sauvegardée
          const savedTheme = localStorage.getItem('preferred-theme');

          // Si une préférence existe, on l'applique immédiatement
          if (savedTheme) {
            document.documentElement.setAttribute('data-bs-theme', savedTheme);
          }
          // Optionnel : Détecter la préférence système si aucune sauvegarde
          else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
            document.documentElement.setAttribute('data-bs-theme', 'dark');
          }
          // Sinon, le thème par défaut du CSS sera utilisé (généralement 'light')

        } catch (error) {
          // En cas d'erreur (localStorage bloqué, etc.), on log discrètement
          // et on laisse le thème par défaut s'appliquer
          console.warn('Impossible de charger la préférence de thème:', error);
        }
      })();
    </script>
    <!-- FIN DU SCRIPT MAGIQUE -->

    <!-- Les feuilles de style sont chargées APRÈS le script -->
    <link rel="stylesheet" href="css/bootstrap.min.css">
    <link rel="stylesheet" href="css/styles.css">

    <!-- Autres ressources du head -->
    <link rel="icon" href="favicon.ico">
</head>
<body>
    <!-- Contenu de la page -->
</body>
</html>

Script Anatomy: Every Line Counts

Let’s break down this script line by line to understand every design decision:

The IIFE (Immediately Invoked Function Expression)

(function() {
  // ...
})();

This structure creates a function that executes immediately. Why? To isolate our variables in a local scope and avoid polluting the global scope. Even if we only use`const`(which has block scope), the IIFE is a good practice that makes our intentions clear and protects against potential naming conflicts.

Strict Mode

'use strict';

This directive activates JavaScript’s strict mode, which: - Prohibits the use of undeclared variables - Generates errors for dangerous operations - Improves performance in some JavaScript engines

For a critical script like this, we want maximum security.

The try…​catch Block

try {
  // Code principal
} catch (error) {
  console.warn('Impossible de charger la préférence de thème:', error);
}

This block is absolutely crucial. It ensures that if something goes wrong (localStorage blocked, improbable syntax error, etc.), our script will not block the loading of the entire page. Using`console.warn`rather than`console.error`indicates that it is a non-critical problem.

Reading localStorage

const savedTheme = localStorage.getItem('preferred-theme');

This line can throw an exception in some contexts (Safari strict private browsing). That is why it is in a try…​catch block.

Conditional Application

if (savedTheme) {
  document.documentElement.setAttribute('data-bs-theme', savedTheme);
}

We apply the theme only if we found a saved one. Otherwise, we let the CSS use its default theme. This approach is more robust than a default value hard-coded in JavaScript.

System Preference Detection (Bonus)

An optional but elegant improvement is to detect the theme preference of the user’s operating system if they have not yet made an explicit choice in your application:

else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
  document.documentElement.setAttribute('data-bs-theme', 'dark');
}

This feature uses the`prefers-color-scheme`Media Query to query the system. On macOS, Windows 10+, iOS, and modern Android, this query returns the user’s system preference.

Advantages: - Personalized experience from the first visit - Consistency with the user’s system environment - No storage required for the first visit

Considerations: - Not all browsers support this feature (but support has been excellent since 2020) - The`window.matchMedia`check ensures compatibility - The user can still override this choice

Performance: Why this Script is Fast

Our preloading script is designed to be extremely fast:

Minimal size: About 300 bytes unminified, 200 bytes minified. This is negligible compared to any image or JavaScript library.

Inline: No additional HTTP request. The script is in the HTML, so it is immediately available.

Simple synchronous operations: Reading a key in localStorage (ultra-fast operation) and modifying a DOM attribute (native browser operation).

No dependencies: No framework, no library, just vanilla JavaScript. No startup time, no dependency parsing.

Single execution: This script executes only once upon loading. No event listeners, no loops, no complex calculations.

In practice, on modern hardware, this script executes in less than 1 millisecond, an imperceptible time that has no impact on the page loading performance.

Optimal Placement in the <head>

The order of elements in the`<head>`is important. Here is the recommended order:

<head>
    <!-- 1. Métadonnées critiques -->
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <!-- 2. Notre script de pré-chargement (IMMÉDIATEMENT après les meta) -->
    <script>
      (function() { /* notre code */ })();
    </script>

    <!-- 3. Titre de la page -->
    <title>Mon Site</title>

    <!-- 4. Feuilles de style -->
    <link rel="stylesheet" href="styles.css">

    <!-- 5. Autres ressources (fonts, favicons, etc.) -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="icon" href="favicon.ico">

    <!-- 6. Autres scripts avec defer ou async -->
    <script src="app.js" defer></script>
</head>

This order ensures that:

  1. The charset is defined before any text processing

  2. Our script executes before the loading of CSS

  3. The CSS is loaded next and directly applies the correct theme

  4. Other non-critical resources are loaded last

Step 3: The Power of CSS Attribute Selectors

The Bootstrap 5 Theme System

Bootstrap 5 introduced an elegant theme management system based on CSS custom properties (CSS variables) and attribute selectors. This system uses the`data-bs-theme`attribute on the`<html>`element to determine which set of color variables to apply.

The beauty of this system lies in its simplicity: instead of loading different stylesheets or toggling classes on thousands of elements, we simply change one attribute on a single element, and the CSS does the rest thanks to the cascade.

CSS Structure for a Theme System

Here is a complete CSS structure to implement a robust theme system:

/**
 * SYSTÈME DE THÈME COMPLET
 * Utilise les Custom Properties CSS pour une maintenance facile
 */

/* ============================================
   THÈME PAR DÉFAUT (LIGHT)
   Défini sur :root pour être le fallback
   ============================================ */
:root {
  /* Couleurs de base */
  --color-primary: #0d6efd;
  --color-secondary: #6c757d;
  --color-success: #198754;
  --color-danger: #dc3545;
  --color-warning: #ffc107;
  --color-info: #0dcaf0;

  /* Couleurs de fond */
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-tertiary: #e9ecef;

  /* Couleurs de texte */
  --text-primary: #212529;
  --text-secondary: #6c757d;
  --text-tertiary: #adb5bd;

  /* Couleurs de bordure */
  --border-color: #dee2e6;
  --border-color-subtle: #e9ecef;

  /* Couleurs d'ombre */
  --shadow-sm: rgba(0, 0, 0, 0.075);
  --shadow-md: rgba(0, 0, 0, 0.15);
  --shadow-lg: rgba(0, 0, 0, 0.25

Related articles