Dark Mode and Color Modes
Bootstrap 5.3 introduced client-side color modes that switch CSS custom properties without Sass recompilation. bslib integrates this via input_dark_mode() and toggle_dark_mode().
Table of Contents
- How Bootstrap Color Modes Work
- input_dark_mode()
- toggle_dark_mode()
- Server-Side Theme Switching
- Client-Side vs Server-Side
- Custom Styles Across Modes
- Component Compatibility
- Performance
- Best Practices for Dark-Mode-Compatible Themes
How Bootstrap Color Modes Work
Bootstrap 5.3 uses the data-bs-theme attribute on HTML elements to switch color modes. When toggled, Bootstrap overrides a set of CSS custom properties (--bs-body-bg, --bs-body-color, --bs-emphasis-color, etc.) without any Sass recompilation.
Global mode (set on <html>):
<html data-bs-theme="light"> <!-- or "dark" -->
Per-element override (scoped to a component):
# This card is always dark, regardless of global mode
tags$div(
`data-bs-theme` = "dark",
card(card_header("Settings"), "Always dark card")
)
Default behavior: If no data-bs-theme is set, Bootstrap respects the user's OS-level prefers-color-scheme preference.
input_dark_mode()
A toggle button that switches between Bootstrap 5.3's light and dark color modes client-side.
input_dark_mode(
..., # Additional HTML attributes (class, style, etc.)
id = NULL, # Input ID to reactively read current mode
mode = NULL # Initial mode: NULL (follow OS), "light", or "dark"
)
Placing in a page_navbar() header:
Wrap input_dark_mode() in nav_item() (which places arbitrary HTML in the navbar) and precede it with nav_spacer() (which pushes all following items to the far right):
page_navbar(
title = "My App",
nav_panel("Dashboard", ...),
nav_panel("Analysis", ...),
nav_spacer(), # pushes everything after it to the right
nav_item(input_dark_mode(id = "color_mode")) # toggle button, far-right of navbar
)
input_dark_mode() can also be placed in a sidebar() or anywhere else in the UI without any wrapper.
Enabling dark mode without a visible toggle:
Pass style = css(display = "none") to activate Bootstrap's color mode system without rendering a button. The app follows the user's OS preference (prefers-color-scheme) by default and can still be driven from the server with toggle_dark_mode():
page_navbar(
title = "My App",
nav_item(input_dark_mode(id = "color_mode", style = css(display = "none"))),
nav_panel("Dashboard", ...)
)
Use this when you want OS-aware dark mode or server-controlled mode changes but don't want to expose a toggle in the UI. Include an id if the server needs to react to or control the mode; omit it if you only want passive OS-following behavior.
Reading the current mode in the server:
output$mode_text <- renderText({
paste("Current mode:", input$color_mode) # "light" or "dark"
})
How it works under the hood:
- User clicks the toggle
input_dark_mode()setsdata-bs-theme="dark"(or"light") on<html>- Bootstrap's pre-compiled CSS variable overrides take effect immediately
- All Bootstrap components and utilities update their colors
- If
idis provided, the server receives"light"or"dark"
No Sass recompilation happens — this is purely a CSS variable switch, making it instantaneous.
toggle_dark_mode()
Programmatically set or toggle the color mode from the server:
toggle_dark_mode(
mode = NULL, # "light", "dark", or NULL to toggle
session = get_current_session()
)
Examples:
# Toggle between modes
observeEvent(input$toggle_btn, {
toggle_dark_mode()
})
# Force a specific mode
observeEvent(input$force_light, {
toggle_dark_mode("light")
})
Server-Side Theme Switching
For more extensive theme changes beyond light/dark (different color palettes, fonts, etc.), use session$setCurrentTheme():
server <- function(input, output, session) {
corporate_theme <- bs_theme(
bg = "#FFFFFF", fg = "#212529",
primary = "#003366",
base_font = font_google("Open Sans")
)
playful_theme <- bs_theme(
bg = "#FFF8E7", fg = "#333333",
primary = "#FF6B35",
base_font = font_google("Nunito")
)
observeEvent(input$theme_choice, {
theme <- switch(input$theme_choice,
"corporate" = corporate_theme,
"playful" = playful_theme
)
session$setCurrentTheme(theme)
})
}
This triggers a full Sass recompilation and CSS replacement via Shiny's connection.
Client-Side vs Server-Side
| Aspect | input_dark_mode() / toggle_dark_mode() |
session$setCurrentTheme() |
|---|---|---|
| Mechanism | Sets data-bs-theme attribute (CSS variable swap) |
Full Sass recompilation + CSS replacement |
| Speed | Instantaneous | Noticeable delay |
| Scope | Light/dark mode only (same Sass, different CSS vars) | Any theme change (colors, fonts, variables) |
| Custom Sass | Only works if styles use CSS custom properties | Custom Sass is recompiled with new values |
| 3rd-party widgets | Only if they use Bootstrap CSS variables | Only if they use bs_dependency_defer() |
Recommendation: Use input_dark_mode() for simple light/dark toggling (faster, no server round-trip). Use session$setCurrentTheme() when you need fundamentally different themes.
Custom Styles Across Modes
Using Sass Variables (recompiled themes)
When using session$setCurrentTheme(), Sass variables adapt automatically:
custom_rules <- "
.custom-card {
background: mix($bg, $primary, 95%);
border: 1px solid $primary;
}
"
light_theme <- bs_theme(bg = "#FFFFFF", fg = "#212529") |> bs_add_rules(custom_rules)
dark_theme <- bs_theme(bg = "#1a1a1a", fg = "#f8f9fa") |> bs_add_rules(custom_rules)
Using CSS Custom Properties (client-side color modes)
When using input_dark_mode() (no recompilation), custom CSS must reference CSS custom properties, not Sass variables:
bs_theme() |>
bs_add_rules("
.custom-card {
/* These update automatically when data-bs-theme changes */
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
border: 1px solid var(--bs-border-color);
}
")
Important: Sass variables like $bg and $primary are resolved at compile time. They don't change when data-bs-theme toggles. Use var(--bs-*) properties for styles that should respond to client-side color mode changes.
Mode-Specific Overrides
Target specific modes in custom CSS:
bs_theme() |>
bs_add_rules("
[data-bs-theme='dark'] .custom-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
[data-bs-theme='light'] .custom-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
")
Component Compatibility
Responds to bs_theme() and Color Modes
Core Shiny UI: All inputs, buttons, tables, text, links.
bslib components: Cards, value boxes, navs/navsets, sidebars, accordions, tooltips, popovers, toasts.
Not Themeable
renderPlot()without thethematicpackage (images are rendered server-side, unaffected by CSS)- HTML widgets with baked-in styles (hardcoded CSS overrides Bootstrap)
- External iframes
- Custom HTML with hardcoded inline styles
R Plots with Dark Mode
The thematic package responds to session$setCurrentTheme() but not to client-side data-bs-theme toggles. For apps using input_dark_mode(), you may need to re-render plots when the mode changes:
output$plot <- renderPlot({
# Re-render when color mode changes
mode <- input$color_mode
bg <- if (identical(mode, "dark")) "#212529" else "#ffffff"
fg <- if (identical(mode, "dark")) "#dee2e6" else "#212529"
ggplot(data, aes(x, y)) +
geom_point(color = fg) +
theme_minimal(base_size = 14) +
theme(
plot.background = element_rect(fill = bg, color = NA),
panel.background = element_rect(fill = bg, color = NA),
text = element_text(color = fg),
axis.text = element_text(color = fg)
)
})
Performance
- Client-side color modes (
input_dark_mode()) are instantaneous — just a CSS variable swap - Server-side theme switching (
session$setCurrentTheme()) triggers Sass recompilation, which can take a noticeable moment for complex themes
Best Practices for Dark-Mode-Compatible Themes
Design the Light Theme First
Bootstrap derives dark mode from light-mode values. Design and finalize your light theme first, then patch dark mode as needed.
Patch Theme Colors for Dark Mode
Sass theme colors ($primary, $success, etc.) compile once for both modes. Colors that work on light backgrounds may lack contrast on dark backgrounds. Patch with CSS property overrides:
bs_theme(primary = "#2c6fbb") |>
bs_add_variables(
"primary-dark" = "#5a9fd4",
.where = "defaults"
) |>
bs_add_rules("
[data-bs-theme='dark'] {
--bs-primary: #{$primary-dark};
--bs-primary-rgb: #{to-rgb($primary-dark)};
}
")
Using a Sass variable with !default placement lets users override $primary-dark via bs_add_variables(), and Bootstrap's to-rgb() derives the RGB components automatically.
Avoid Hardcoded Colors
Hardcoded hex values won't respond to mode changes. Use var(--bs-*) properties, or define custom properties that switch per mode:
bs_theme() |>
bs_add_rules("
:root, [data-bs-theme='light'] {
--my-surface: #f0f4f8;
--my-surface-text: #1a2b3c;
}
[data-bs-theme='dark'] {
--my-surface: #1e2a38;
--my-surface-text: #c8d6e0;
}
.my-surface {
background: var(--my-surface);
color: var(--my-surface-text);
}
")
Test Contrast in Both Modes
Colors meeting WCAG contrast in light mode may fail in dark mode. Toggle between modes and verify:
- Text readability against backgrounds
- Primary-colored buttons and links
- Borders and dividers visible but not overpowering
- Status colors (success, warning, danger) remain distinguishable
Keep Dark Mode Overrides Minimal
Bootstrap's built-in dark derivations handle most components well. Focus overrides on:
- Theme colors with confirmed contrast issues in dark mode
- Custom components with colors outside Bootstrap's system
- Shadows (dark backgrounds need subtler or lighter shadows)