Colour Modes in Depth: Auto & Custom Themes
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):
[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: darktells the browser itself to use dark scrollbars and dark native controls (date pickers, for example).- Brand colours do not change.
--bs-primaryand--bs-primary-rgbare the same in both modes, sobtn-primary,bg-primaryandtext-primarylook identical in light and dark. Only the-subtle,-emphasis,bodyandlinkfamilies adapt. - The attribute only sets variables. On
<html>, the<body>reads--bs-body-bgand 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 asbg-bodyorbg-body-tertiary.
<!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.
<!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].
.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:
.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:
<!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-primaryortext-primaryto change in dark mode. Brand colours stay fixed; use-emphasisand-subtleclasses for text and panels that should adapt. - Putting
data-bs-theme="dark"on a<div>and seeing no change. The attribute changes variables only; addbg-body text-bodyor another background class. - Writing "auto" into
data-bs-theme. Bootstrap has noautomode; resolve it tolightordarkin JavaScript. - Not listening for system changes. An "Auto" page that ignores the
changeevent 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 setscolor-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 forchange. - Make custom components mode-aware with Bootstrap variables, or your own variables overridden in
[data-bs-theme=dark]or thecolor-mode()mixin. $color-mode-type: media-queryfollows the system with no JavaScript;$enable-dark-mode: falseremoves dark mode.- Custom modes such as
[data-bs-theme="sepia"]override the body, background, border and link variables (and their-rgbtwins), 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
<!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?
Components read the variables, so new values change their look.
2. Which class keeps the same colour in light and dark mode?
--bs-primary is not redefined in dark mode; subtle and body colours are.
3. How should a page set to "Auto" react when the operating system switches to dark?
Bootstrap has no auto value; your script resolves and re-applies it when the media query changes.
4. What does $color-mode-type: media-query do?
The color-mode() mixin then outputs a media query, so no attribute or script is needed (and per-component theming is lost).
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
