EthicsComputerCourses
Join training +91 90532 10052

Colour Modes in Depth: Auto & Custom Themes

Bootstrap · Lesson 33 of 40 Advanced 8 min read Updated

In Colours & Dark Mode you switched a page to dark with data-bs-theme="dark" and built a two-way toggle. Production apps usually need more: a third "Auto" choice that follows the phone or laptop setting and keeps following it when it changes, your own components that look right in both modes, and sometimes an extra theme such as a soft "reading" mode for a news or e-book site.

In this lesson you will look inside Bootstrap's colour modes, build a light/dark/auto switcher step by step, make custom components mode-aware with CSS variables and Sass, and create a custom colour mode.

How colour modes really work

A colour mode is nothing more than a different set of values for Bootstrap's CSS variables. The compiled CSS contains a rule like this (shortened):

CSS
[data-bs-theme=dark] {
  color-scheme: dark;
  --bs-body-color: #dee2e6;
  --bs-body-bg: #212529;
  --bs-secondary-bg: #343a40;
  --bs-tertiary-bg: #2b3035;
  --bs-border-color: #495057;
  --bs-link-color: #6ea8fe;
  --bs-primary-bg-subtle: #031633;
  --bs-primary-text-emphasis: #6ea8fe;
  /* ...about fifty variables in all */
}

Components never use fixed colours for surfaces and text; they read these variables. So when the attribute changes, the variables change, and every component that reads them changes too. Three consequences follow, and each one explains a bug you will meet:

  • color-scheme: dark tells the browser itself to use dark scrollbars and dark native controls (date pickers, for example).
  • Brand colours do not change. --bs-primary and --bs-primary-rgb are the same in both modes, so btn-primary, bg-primary and text-primary look identical in light and dark. Only the -subtle, -emphasis, body and link families adapt.
  • The attribute only sets variables. On <html>, the <body> reads --bs-body-bg and turns dark. But on a plain <div data-bs-theme="dark">, the div's own background stays transparent until you also give it a class such as bg-body or bg-body-tertiary.
HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Inside colour modes</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container py-4">
    <div class="row g-3">
      <div class="col-sm-4"><div class="p-3 border rounded" data-bs-theme="dark" id="a">Dark, no class: still looks light</div></div>
      <div class="col-sm-4"><div class="p-3 border rounded bg-body text-body" data-bs-theme="dark" id="b">Dark with <code>bg-body</code></div></div>
      <div class="col-sm-4"><div class="p-3 border rounded bg-body text-body" data-bs-theme="light" id="c">Light with <code>bg-body</code></div></div>
    </div>
    <div class="mt-3 d-flex gap-2" data-bs-theme="dark">
      <span class="badge text-bg-primary">primary: same in both</span>
      <span class="badge bg-primary-subtle text-primary-emphasis">primary-subtle: adapts</span>
    </div>
    <pre class="mt-3 p-2 bg-body-tertiary rounded" id="out"></pre>
  </div>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const read = (id, name) => getComputedStyle(document.getElementById(id)).getPropertyValue(name).trim();
    document.getElementById("out").textContent = [
      "--bs-body-bg   dark: " + read("b", "--bs-body-bg") + "   light: " + read("c", "--bs-body-bg"),
      "--bs-primary   dark: " + read("b", "--bs-primary") + "   light: " + read("c", "--bs-primary"),
      "First box background: " + getComputedStyle(document.getElementById("a")).backgroundColor,
    ].join("\n");
  </script>
</body>
</html>

Building a light/dark/auto switcher

Bootstrap's own documentation site uses a three-way switch, and it has become the expected pattern. You will build your own version in four steps.

Step 1: decide what to store

Store the user's choice (light, dark or auto) in localStorage, and compute the mode to apply. "Auto" is never written to data-bs-theme; it is resolved to light or dark with matchMedia("(prefers-color-scheme: dark)").

Step 2: apply it before the page paints

Put a small script in the <head>, before the stylesheet, so the first paint already has the right colours (no white flash). It reads the stored choice, defaulting to auto, and sets the attribute.

Step 3: the menu

Use a dropdown with three buttons carrying a data-theme-value attribute. Mark the current choice with aria-pressed="true" and the active class, and show it in the toggle's text, so users of screen readers know which option is on.

Step 4: follow system changes

Listen for the change event on the media query. When the user's operating system switches to dark at sunset, a page set to "Auto" should follow, while a page set to "Light" or "Dark" should not.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Light, dark and auto</title>
  <script>
    // Step 2: runs before the first paint
    const media = matchMedia("(prefers-color-scheme: dark)");
    const getChoice = () => localStorage.getItem("theme-choice") ?? "auto";
    const resolve = (choice) => choice === "auto" ? (media.matches ? "dark" : "light") : choice;
    document.documentElement.setAttribute("data-bs-theme", resolve(getChoice()));
  </script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <nav class="navbar bg-body-tertiary border-bottom">
    <div class="container">
      <span class="navbar-brand">Ananya's Recipe Box</span>
      <div class="dropdown">
        <button class="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" id="themeBtn">
          Theme: <span id="themeLabel">auto</span>
        </button>
        <ul class="dropdown-menu dropdown-menu-end">
          <li><button type="button" class="dropdown-item" data-theme-value="light" aria-pressed="false">Light</button></li>
          <li><button type="button" class="dropdown-item" data-theme-value="dark" aria-pressed="false">Dark</button></li>
          <li><button type="button" class="dropdown-item" data-theme-value="auto" aria-pressed="false">Auto (follow system)</button></li>
        </ul>
      </div>
    </div>
  </nav>
  <main class="container py-4">
    <div class="card"><div class="card-body">Paneer butter masala: 40 minutes, serves 4.</div></div>
    <p class="mt-3 small text-body-secondary" id="status"></p>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    // Step 3: show and change the choice
    function showChoice(choice) {
      document.getElementById("themeLabel").textContent = choice;
      document.querySelectorAll("[data-theme-value]").forEach((btn) => {
        const on = btn.dataset.themeValue === choice;
        btn.classList.toggle("active", on);
        btn.setAttribute("aria-pressed", on);
      });
      document.getElementById("status").textContent =
        `Choice: ${choice}, mode in use: ${document.documentElement.getAttribute("data-bs-theme")}`;
    }
    function setChoice(choice) {
      localStorage.setItem("theme-choice", choice);
      document.documentElement.setAttribute("data-bs-theme", resolve(choice));
      showChoice(choice);
    }
    document.querySelectorAll("[data-theme-value]").forEach((btn) => {
      btn.addEventListener("click", () => setChoice(btn.dataset.themeValue));
    });
    // Step 4: follow the system only when the choice is "auto"
    media.addEventListener("change", () => {
      if (getChoice() === "auto") setChoice("auto");
    });
    showChoice(getChoice());
  </script>
</body>
</html>

The functions media, getChoice and resolve are created in the <head> script and used again at the end of the body. That works because classic scripts on one page share the same global scope. In a module-based project you would put them in one module instead.

Making your own components mode-aware

Your own CSS is where dark mode usually breaks: a .promo { background: #fff8e1; } box stays bright yellow on a dark page. Two rules fix almost everything.

Rule 1: build on Bootstrap's variables. Use var(--bs-body-bg), var(--bs-secondary-bg), var(--bs-border-color), var(--bs-emphasis-color) and the -subtle and -emphasis colours in your CSS. They already have light and dark values.

Rule 2: for brand colours, define your own variable and give it a dark value. Follow Bootstrap's naming idea: set the variable on the component, and override it inside [data-bs-theme=dark].

CSS
.promo {
  --promo-bg: #fff3cd;
  --promo-color: #664d03;
  background: var(--promo-bg);
  color: var(--promo-color);
  border: 1px solid var(--bs-border-color);
}
[data-bs-theme=dark] .promo {
  --promo-bg: #332701;
  --promo-color: #ffda6a;
}

If you build with Sass, Bootstrap's color-mode() mixin writes that selector for you, and it also respects the $color-mode-type setting described below:

SCSS
.promo {
  --promo-bg: #{$warning-bg-subtle};
  --promo-color: #{$warning-text-emphasis};
}

@include color-mode(dark) {
  .promo {
    --promo-bg: #{$warning-bg-subtle-dark};
    --promo-color: #{$warning-text-emphasis-dark};
  }
}

With the default data type this compiles to [data-bs-theme=dark] .promo { ... }. Use the mixin at the top level as shown. If you nest it inside .promo { }, Sass compiles it to .promo [data-bs-theme=dark], which looks for a dark element inside the promo box and never matches. Bootstrap's Sass has a -dark twin for many variables ($body-bg-dark, $border-color-dark, $link-color-dark, $primary-bg-subtle-dark and so on) in the _variables-dark.scss file; set them before importing Bootstrap to change the dark palette for the whole site.

Creating a custom colour mode

Because a mode is only a selector plus variables, you can add your own. Pick a name, write [data-bs-theme="sepia"], and override the body, background and border variables. Here is a warm "reading" mode for a long-article site:

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en" data-bs-theme="sepia">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Custom colour mode</title>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    [data-bs-theme="sepia"] {
      color-scheme: light;
      --bs-body-color: #433422;
      --bs-body-color-rgb: 67, 52, 34;
      --bs-body-bg: #f4ecd8;
      --bs-body-bg-rgb: 244, 236, 216;
      --bs-emphasis-color: #2a1f12;
      --bs-emphasis-color-rgb: 42, 31, 18;
      --bs-secondary-color: rgba(67, 52, 34, .75);
      --bs-secondary-bg: #e9dcc0;
      --bs-secondary-bg-rgb: 233, 220, 192;
      --bs-tertiary-bg: #efe4cc;
      --bs-tertiary-bg-rgb: 239, 228, 204;
      --bs-border-color: #d3c29e;
      --bs-link-color: #8a4b08;
      --bs-link-color-rgb: 138, 75, 8;
      --bs-link-hover-color-rgb: 102, 54, 4;
    }
  </style>
</head>
<body>
  <main class="container py-4" style="max-width: 640px">
    <label for="mode" class="form-label">Reading mode</label>
    <select id="mode" class="form-select mb-4" style="max-width: 200px">
      <option value="sepia" selected>Sepia</option>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
    <article>
      <h1 class="h3">The night train to Lucknow</h1>
      <p class="text-body-secondary">Short story, 6 minute read</p>
      <p>Rohan pressed his face to the window as the lights of Kanpur slipped away. <a href="#next">Read chapter 2</a>.</p>
      <div class="p-3 bg-body-tertiary border rounded">Author's note: written during a 2026 workshop.</div>
    </article>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    document.getElementById("mode").addEventListener("change", (event) => {
      document.documentElement.setAttribute("data-bs-theme", event.target.value);
    });
  </script>
</body>
</html>

Text, links, the secondary text, the tertiary panel and even the select box follow the new mode. Notice that most colours are set twice, for example --bs-tertiary-bg and --bs-tertiary-bg-rgb. Utilities such as bg-body-tertiary and bg-body read the -rgb version (so they can add opacity), while components read the plain one. Forget the -rgb twin and the panel stays grey. The official docs add one warning, and it is worth repeating: some components set their own colours in component variables (dropdown menus and btn-secondary, for example), so a complete custom mode also overrides those, the way you learned in Customising with CSS Variables & Sass. Always check every component your site uses in the new mode, and check contrast (see Colour & Contrast).

Common mistakes

  • Expecting btn-primary or text-primary to change in dark mode. Brand colours stay fixed; use -emphasis and -subtle classes for text and panels that should adapt.
  • Putting data-bs-theme="dark" on a <div> and seeing no change. The attribute changes variables only; add bg-body text-body or another background class.
  • Writing "auto" into data-bs-theme. Bootstrap has no auto mode; resolve it to light or dark in JavaScript.
  • Not listening for system changes. An "Auto" page that ignores the change event stays light after the laptop switches to dark.
  • Hard-coding hex colours in your own CSS. Use Bootstrap variables, or your own variables with a dark override.

Key points

  • A colour mode is a selector, such as [data-bs-theme=dark], that redefines Bootstrap's CSS variables and sets color-scheme.
  • Brand colours (--bs-primary) are the same in all modes; body, secondary, tertiary, border, link, subtle and emphasis colours change.
  • A light/dark/auto switch stores the choice, resolves "auto" with matchMedia, sets the attribute in the <head> and listens for change.
  • Make custom components mode-aware with Bootstrap variables, or your own variables overridden in [data-bs-theme=dark] or the color-mode() mixin.
  • $color-mode-type: media-query follows the system with no JavaScript; $enable-dark-mode: false removes dark mode.
  • Custom modes such as [data-bs-theme="sepia"] override the body, background, border and link variables (and their -rgb twins), plus any component variables needed.

Exercise

Create a "high-contrast" colour mode for a Jaipur government-style notice page: pure black text (#000) on white (#fff), black borders, and dark blue links (#0033cc). Add a three-option select (Light, Dark, High contrast) that applies the mode and remembers it in localStorage. Include a card with a bg-body-tertiary panel and a link so you can check that everything follows the new mode.

Show solution
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Notices</title>
  <script>
    document.documentElement.setAttribute("data-bs-theme", localStorage.getItem("mode") ?? "light");
  </script>
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
    [data-bs-theme="contrast"] {
      color-scheme: light;
      --bs-body-color: #000;
      --bs-body-color-rgb: 0, 0, 0;
      --bs-body-bg: #fff;
      --bs-body-bg-rgb: 255, 255, 255;
      --bs-emphasis-color: #000;
      --bs-secondary-color: #000;
      --bs-tertiary-bg: #fff;
      --bs-tertiary-bg-rgb: 255, 255, 255;
      --bs-border-color: #000;
      --bs-link-color: #0033cc;
      --bs-link-color-rgb: 0, 51, 204;
    }
  </style>
</head>
<body>
  <main class="container py-4">
    <label for="mode" class="form-label">Display</label>
    <select id="mode" class="form-select mb-3" style="max-width: 220px">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="contrast">High contrast</option>
    </select>
    <div class="card"><div class="card-body">
      <div class="p-3 bg-body-tertiary border rounded mb-2">Water supply closed on Sunday, 9 am to 1 pm.</div>
      <a href="#all">All notices</a>
    </div></div>
  </main>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
  <script>
    const select = document.getElementById("mode");
    select.value = document.documentElement.getAttribute("data-bs-theme");
    select.addEventListener("change", () => {
      document.documentElement.setAttribute("data-bs-theme", select.value);
      localStorage.setItem("mode", select.value);
    });
  </script>
</body>
</html>

The custom mode only redefines variables, so cards, panels, borders and links switch with no component-specific CSS; the stored value is applied in the <head> to avoid a flash.

Quick Quiz

1. What does [data-bs-theme=dark] actually change?

2. Which class keeps the same colour in light and dark mode?

3. How should a page set to "Auto" react when the operating system switches to dark?

4. What does $color-mode-type: media-query do?

Learn Bootstrap with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call