Having learnt that Bootstrap supports color modes, we decided to implement an option for users to enable dark mode in Debusine.
By default, the color mode is selected depending on the user browser preferences. If explicitly selected, we use a cookie to store the theme selection so that a user can choose different color modes in different browsers.
The work is in merge request !2401 and minimizes JavaScript dependencies like we do in other parts of debusine.
A view to select the theme
First is a simple view to configure the selected theme and store it in a
cookie. If auto is selected, then the cookie is deleted to delegate theme
selection to JavaScript:
class ThemeSelectionView(View):
"""Select and save the current theme."""
def post(
self, request: HttpRequest, *args: Any, **kwargs: Any # noqa: U100
) -> HttpResponse:
"""Set the selected theme."""
value = request.POST.get("theme", "auto")
next_url = request.POST.get("next", None)
if next_url is None:
next_url = reverse("homepage:homepage")
response = HttpResponseRedirect(next_url)
if value == "auto":
response.delete_cookie("theme")
else:
response.set_cookie(
"theme", value, httponly=False, max_age=dt.timedelta(days=3650)
)
return response
The main base view of Debusine reads the value from the cookie and makes it available to the templates:
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
ctx = super().get_context_data(**kwargs)
ctx["theme"] = self.request.COOKIES.get("theme", None)
# ...
return ctx
The base template will use this value to set data-bs-theme on the main
<html> element, and that’s all that is needed to select the color mode in
Bootstrap:
<html lang="en"{% if theme %} data-bs-theme="{{ theme }}"{% endif %}>
The view uses HTTP POST as it changes state, so theme selection happens in a form:
<form id="footer-theme" class="col-auto" method="post"
action="{% url "theme-selection" %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
Theme:
<button type="submit" name="theme" value="dark">dark</button>
•
<button type="submit" name="theme" value="light">light</button>
•
<button type="submit" name="theme" value="auto">auto</button>
</form>
Since we added the theme selection buttons in the footer, we use CSS to render the buttons in the same way as the rest of the footer links.
Bootstrap has a set of CSS variables that can be used to easily in sync with the site theme, and they are especially useful now that the theme is configurable:
footer button {
background: none;
border: none;
margin: 0;
padding: 0;
color: var(--bs-link-color);
}
Theme autoselection
Bootstrap would support theme autoselection via browser preferences, but that requires rebuilding its Sass sources.
Alternatively, one can use JavaScript:
{% if not theme %}
<script blocking="render">
(function() {
let theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
let [html] = document.getElementsByTagName("html");
html.setAttribute("data-bs-theme", theme);
})();
</script>
{% endif %}
This reads the color scheme preferences and sets the data-bs-theme attribute
on <html>.
The script is provided inline as it needs to use blocking="render" to avoid
flashing a light background at the beginning of page load until the attribute
is set.
Given that this is a render-blocking snippet, as an extra optimization it is not added to the page if a theme has been set.
Bootstrap CSS fixes
We were making use of the bootstrap btn-light class in navbars to highlight
elements on hover, and that doesn’t work well with theme selection.
Lacking a button class that does the right thing across themes, we came up with a new CSS class that uses variables to define a button with hover highlight that works preserving the underlying color:
:root[data-bs-theme=light] {
--debusine-hover-layer: rgb(0 0 0 / 20%);
--debusine-hover-color-multiplier: 0.8;
--debusine-disabled-color-multiplier: 1.5;
}
:root[data-bs-theme=dark] {
--debusine-hover-layer: rgb(255 255 255 / 20%);
--debusine-hover-color-multiplier: 1.2;
--debusine-disabled-color-multiplier: 0.5;
}
/* Button that preserves the underlying color scheme */
.btn-debusine {
--bs-btn-hover-color: rgb(from var(--bs-btn-color) calc(r * var(--debusine-hover-color-multiplier)) calc(g * var(--debusine-hover-color-multiplier)) calc(b * var(--debusine-hover-color-multiplier)));
--bs-btn-hover-bg: var(--debusine-hover-layer);
--bs-btn-disabled-color: rgb(from var(--bs-btn-color) calc(r * var(--debusine-disabled-color-multiplier)) calc(g * var(--debusine-disabled-color-multiplier)) calc(b * var(--debusine-disabled-color-multiplier)));
--bs-btn-disabled-bg: var(--bs-btn-bg);
--bs-btn-disabled-border-color: var(--bs-btn-border-color);
}
Dark mode!
This was a nice integration exercise with many little tricks, like how to read color scheme preferences from the browser, render form buttons as links, use bootstrap variables, prevent a flashing background, handle cookies in Django.
And Debusine now has a dark mode!