Dark mode is usually requested as a small visual feature and delivered as a long tail of bugs. The buttons and cards flip over fine—Bootstrap 5 handles those—and then a chart renders black text on a dark canvas, a customer logo sits in a glowing white box, every card loses its edges because shadows are invisible on dark backgrounds, and the whole page flashes white for 200ms on every reload.
None of that is a color problem. Bootstrap 5 dark mode is a theming problem: it works when every color in your dashboard comes from a variable, and it breaks everywhere a color was written down as a hex value. This guide covers the mechanism, a switcher that does not flash, and the specific parts of an admin interface that need extra work.
How Bootstrap 5 Color Modes Work
Bootstrap 5.3 replaced the old “build a second stylesheet” approach with a single attribute. Setting data-bs-theme swaps the CSS custom properties that every component reads:
<html lang="en" data-bs-theme="dark">
Components do not contain hardcoded colors anymore. A card’s background is var(--tblr-bg-surface), body text is var(--tblr-body-color), and the dark theme simply redefines those variables further down the cascade. Nothing needs to re-render, and no stylesheet gets swapped.
Because it is an attribute rather than a global flag, it also nests. Any element can open its own color context:
<body data-bs-theme="light">
<div class="card" data-bs-theme="dark">
<div class="card-body">This card stays dark on a light page.</div>
</div>
</body>
That is genuinely useful in dashboards—a dark sidebar next to a light content area, a code preview panel, a marketing-style hero inside an app shell—without a single override.
The attribute also sets the CSS color-scheme property, which is the part teams forget. color-scheme: dark tells the browser to render native UI—scrollbars, form control internals, date pickers, the canvas behind your page—in dark variants. Skip it and you get dark cards with bright white scrollbars.
Turning It On in Tabler
Tabler ships both themes in one CSS file, so dark mode needs no extra build. The attribute alone works:
<html lang="en" data-bs-theme="dark">
The template also includes a small theme script that reads a URL parameter and persists the choice:
<body>
<script src="./dist/js/tabler-theme.min.js"></script>
Loading ?theme=dark sets the attribute, writes tabler-theme to localStorage, and every later visit restores it. The same mechanism drives four other appearance settings, each with its own attribute and storage key:
| Setting | Attribute | Values |
|---|---|---|
| Color mode | data-bs-theme | light, dark |
| Neutral palette | data-bs-theme-base | gray, slate, zinc, neutral, stone |
| Accent color | data-bs-theme-primary | blue, azure, indigo, purple, green, and more |
| Corner radius | data-bs-theme-radius | 0–1.5 |
| Font | data-bs-theme-font | sans-serif, serif, monospace |
Worth knowing before you rely on it: the bundled script defaults to light and does not follow the operating system setting. If you want system-aware behavior—and in 2026 users expect it—you write that part yourself. It is about fifteen lines.
A Switcher That Does Not Flash
The flash of the wrong theme happens for a boring reason: the browser paints the default light page before your JavaScript runs. Deferred scripts, bundled scripts, and anything loaded at the end of <body> are all too late.
The fix is a small blocking script inline in <head>, before any stylesheet-driven paint:
<head>
<script>
(function () {
const stored = localStorage.getItem('theme'); // 'light' | 'dark' | 'system' | null
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const theme = !stored || stored === 'system' ? system : stored;
document.documentElement.setAttribute('data-bs-theme', theme);
})();
</script>
<link rel="stylesheet" href="./dist/css/tabler.min.css" />
</head>
Yes, this is a render-blocking inline script, and yes, that is the correct trade-off—it runs in well under a millisecond and prevents a visible flash on every navigation.
Store three states, not two. “Light”, “dark”, and “system” are different answers, and collapsing “system” into whichever value it currently resolves to means a user who chose follow my system stops following it after the first sunset:
function setTheme(choice) {
localStorage.setItem('theme', choice); // 'light' | 'dark' | 'system'
applyTheme();
}
function applyTheme() {
const stored = localStorage.getItem('theme') || 'system';
const system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-bs-theme', stored === 'system' ? system : stored);
}
// Follow the OS while the tab is open, but only when the user picked "system".
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
If your dashboard is server-rendered and users are logged in, persist the choice on the user record too and render the attribute server-side. localStorage is per-device; an account-level preference follows people to their second machine.
What Dark Mode Actually Breaks
Everything above takes an afternoon. The following is where the remaining week goes.
Charts
Charting libraries draw into a canvas or generate inline SVG with their own color options. They never read your CSS variables, so axis labels, gridlines, and tooltips keep their light-theme colors on a dark background. The fix is to read the computed variables at render time and re-render when the theme changes:
function chartColors() {
const styles = getComputedStyle(document.documentElement);
return {
text: styles.getPropertyValue('--tblr-body-color').trim(),
grid: styles.getPropertyValue('--tblr-border-color').trim(),
primary: styles.getPropertyValue('--tblr-primary').trim(),
};
}
Wire that into your chart options, and rebuild the chart inside the same function that flips the attribute. A MutationObserver on documentElement attributes works well if the toggle lives far from your chart code.
Images and logos
Screenshots, customer logos, illustrations, and any PNG with a baked-in white background become bright rectangles. Three fixes, in order of quality:
- Ship both variants and swap with CSS (
[data-bs-theme="dark"] .logo-light { display: none; }) — best result, most work. - Use SVG with
fill="currentColor"so the artwork inherits text color. This is why an icon set built oncurrentColorneeds no dark-mode work at all. - As a last resort, soften the offender:
[data-bs-theme="dark"] .logo { filter: brightness(.9); }. Never blanket-invert images—photos and screenshots come out looking like negatives.
Elevation
Shadows communicate depth by darkening the surface beneath an element. On a dark background there is nothing left to darken, so drop shadows quietly stop existing and every card floats in the same plane. Dark interfaces express hierarchy with lighter surfaces and visible borders instead—which is exactly what --tblr-bg-surface and --tblr-border-color already do in the dark theme, provided your custom components use them rather than a hardcoded box-shadow.
Contrast
Pure white text on pure black is the classic mistake: maximum contrast causes halation, where light text visibly bleeds at the edges and long reading sessions become tiring. Tabler’s dark theme uses #e5e7eb on #111827 for this reason.
Meanwhile, colors tuned for a light background often fail in the other direction. A mid-tone text-warning that hits 4.6:1 on white can drop below 3:1 on a dark surface. Semantic colors need checking in both themes—run a contrast audit twice, and treat the dark pass as a separate task rather than an afterthought.
Everything embedded
Third-party widgets, maps, iframes, syntax highlighters, and rich-text editors have their own theming and will not follow your attribute. Each needs its own theme prop wired to the same state. Transactional email is in the same category—your app’s dark mode has no effect there, and HTML email dark mode follows entirely different rules.
A Testing Checklist Before You Ship
Dark mode regressions hide in states nobody screenshots. Walk both themes through:
- Empty, loading, and error states — skeletons and placeholder illustrations are frequently hardcoded gray.
- Disabled and read-only form controls — the usual “gray it out” approach can leave text unreadable.
- Hover, focus, and active states — focus rings tuned for light backgrounds often disappear on dark ones. Keyboard navigation must remain visible in both.
- Toasts, modals, dropdowns, and tooltips — anything rendered into a portal at the end of
<body>can escape a scopeddata-bs-themecontext. - Tables with status badges — the densest color usage in most dashboards.
- Print — force a light context for print stylesheets unless you enjoy angry emails about toner.
The Practical Order of Work
If you are retrofitting dark mode into an existing Bootstrap 5 dashboard, do it in this sequence:
- Grep for hex values in your own CSS and replace them with Tabler’s CSS variables. This is the actual migration; the attribute is trivial once it is done.
- Add the blocking script and the three-state toggle. Ship it to your team first, so the audit happens in real use.
- Fix charts and images, the two categories that never fix themselves.
- Run contrast checks in dark mode as a separate pass.
- Add both themes to your review checklist, so new screens do not reintroduce hardcoded colors.
Dark mode stays cheap only if it stays a variable system. Every hardcoded color is a future bug in exactly one theme—and the theme you are not looking at is the one your users will find it in. If you want a baseline where both themes are already consistent across every component, the Tabler admin template ships them together, and the customization docs cover the variables to build on.


