Dark Mode Toggle for Drupal Sites with CSS and JavaScript
Dark mode has shifted from a novelty into an expectation, especially for users browsing late at night or working through long reading sessions. For Australian Drupal developers building sites for clients in cities like Sydney, Melbourne, and Brisbane, offering a theme switcher is no longer optional — it forms part of meeting accessibility expectations mirrored in the Digital Transformation Agency's design standards. A well-built dark mode toggle reduces eye strain during evening commutes on the Sydney ferry service or quiet work sessions in Adelaide cafés, and it gives site visitors a tangible reason to stay longer on the page.
This walkthrough covers a practical implementation path using custom CSS variables, vanilla JavaScript, and a small custom module. The approach avoids heavy third-party libraries, keeps markup semantic, and respects user preferences persisted across visits. Along the way, it ties into Drupal's theme layer so editors can curate separate colour palettes through the admin UI rather than touching code.
Planning the Theme Switcher Architecture
Before writing a single line of CSS, sketch the responsibilities of each layer. The browser handles the immediate theme switch, JavaScript captures the user's choice and persists it, and Drupal supplies the default palette based on system context. A clean separation ensures that disabling JavaScript still yields a usable site — visitors get the default light theme rather than a broken layout with missing contrast.
Consider the role of prefers-color-scheme. Australian readers using Safari on a coastal trip from Cairns to Perth often toggle system dark mode based on ambient brightness. Hooking the default state to this media query gives a sensible starting point before any custom JavaScript runs. From there, JavaScript upgrades the experience by allowing manual override and remembering the choice between sessions.
Think about the toggle control as well. A common pattern places it in the header next to search and language switchers, but the placement should align with the site's existing navigation. For Drupal sites built with Olivero as the parent theme, the user account menu is a natural landing spot since the preference is per-visitor rather than global. Keep the toggle icon set consistent with the rest of the site's iconography.
Building the CSS Foundations for Both Themes
CSS custom properties are the cleanest way to manage two palettes side by side. Define a token set on the :root selector and override it under a data-theme="dark" attribute. The browser repaints instantly because every colour reference flows through variables rather than hard-coded hex values.
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-link: #0066cc;
--color-border: #e0e0e0;
}
[data-theme="dark"] {
--color-bg: #121212;
--color-text: #f0f0f0;
--color-link: #4d9fff;
--color-border: #2a2a2a;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
}
a {
color: var(--color-link);
}
Keep contrast ratios in mind. The Web Content Accessibility Guidelines recommend at least 4.5:1 for body text, which the Australian Human Rights Commission reinforces through its guidance on inclusive digital services. Run both palettes through a contrast checker before shipping. For site owners in regulated industries like healthcare or finance — both well-represented in Sydney and Melbourne — failing accessibility audits can mean remediation costs far higher than the time spent tuning variables.
Save the CSS into a sub-theme stylesheet rather than the parent theme. This keeps overrides intact through core updates and makes future redesigns simpler.
Creating the JavaScript Controller Logic
The controller should read the stored preference, apply it to the <html> element, and bind a click handler to the toggle button. Avoid inline scripts because Drupal's cache layer can strip them on anonymous traffic. Attach the script through a library definition in the theme's *.libraries.yml file and load it on every page via the page or html template suggestion.
A typical controller reads:
(function () {
var STORAGE_KEY = 'drupal-dark-mode';
var root = document.documentElement;
var stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light') {
root.setAttribute('data-theme', stored);
}
function applyTheme(value) {
root.setAttribute('data-theme', value);
try {
localStorage.setItem(STORAGE_KEY, value);
} catch (e) {
// Storage may be unavailable in private browsing.
}
}
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.querySelector('[data-dark-mode-toggle]');
if (!toggle) return;
toggle.addEventListener('click', function () {
var current = root.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
toggle.setAttribute('aria-pressed', String(next === 'dark'));
});
});
})();
The aria-pressed attribute makes the toggle accessible to screen readers, a detail that matters under the Disability Discrimination Act 1992. Without it, users navigating with VoiceOver or NVDA miss the state change entirely.
Pairing the controller with consistent markup in the page template is the next step. Add the toggle button inside the header region so it appears on every authenticated and anonymous page that uses the theme.
Storing User Preferences Across Sessions
Three mechanisms dominate preference storage in Drupal sites: browser localStorage, HTTP cookies, and the Drupal session table. Each carries trade-offs relevant to compliance and performance.
| Storage method | Persistence | Server-side access | Privacy implications |
|---|---|---|---|
| localStorage | Until cleared by user | Not accessible | Stays on device, no server round-trip |
| HTTP cookie | Configurable expiry | Available on every request | Subject to Privacy Act 1988 disclosure |
| Drupal session | Cookie + database row | Native to PHP | Requires consent under APP guidelines |
For most Drupal sites targeting Australian audiences, localStorage strikes the right balance. It avoids the consent fatigue triggered by cookie banners required under the Privacy Act 1988 and the Australian Privacy Principles, while still holding the preference for repeat visits. Reserve cookies for cases where the theme choice needs to flow into server-rendered emails or personalised content — a frequent requirement for membership organisations in Canberra and Brisbane.
When localStorage is unavailable, such as in private browsing modes on Safari in Australia, fall back gracefully. The script above swallows the exception silently and lets the media query decide. A degraded experience is preferable to a broken site that loses returning visitors.
Hooking Into Drupal's Theme System
Editors often want control over colour palettes without filing a code change request. A lightweight custom module can expose a settings form for primary, background, and accent colours, then push the values into drupalSettings for the front-end script to consume. This pattern keeps content teams in control while developers retain ownership of the architecture.
Inside the module, implement a hook_form_system_theme_settings_alter() to add fields to the theme settings page. Save values to the active configuration, then load them in a preprocess_html() hook so they reach the template layer as variables. The JavaScript controller picks them up from drupalSettings.theme.darkColors and applies them as inline styles on load.
function mytheme_preprocess_html(&$variables) {
$config = \Drupal::config('mytheme.settings');
$variables['#attached']['drupalSettings']['mytheme']['darkColors'] = [
'bg' => $config->get('dark_bg') ?: '#121212',
'text' => $config->get('dark_text') ?: '#f0f0f0',
];
}
For more elaborate integrations — such as swapping entire layout variants between themes — pair this approach with the guidance in How to Create a Custom Drupal Service Provider for Module Alterations Guide. Service providers give you a clean entry point for registering event subscribers that react to theme changes without polluting .theme files with logic that belongs elsewhere.
Finally, ensure the toggle button's template renders correctly in both authenticated and anonymous contexts. Drupal's render cache can serve stale markup to anonymous users if the cache bins are not configured to vary by theme. Add 'cache.max-age' => 0 on the toggle render array, or vary the cache by the stored theme value to guarantee a fresh button label on each request.
Testing Across Browsers and Devices
A toggle that works in Chrome but breaks in Safari is a toggle that loses trust. Test on the browsers most common among Australian audiences, which still skew toward Safari on macOS and iOS, Chrome on Windows and Android, and Edge on corporate devices. Each handles prefers-color-scheme slightly differently, particularly when combined with reader modes in extensions popular with editorial teams in publishing hubs like Melbourne.
Validate the toggle against keyboard navigation alone. Australian accessibility consultants regularly flag focus traps and missing focus styles during audits, and a toggle that loses its outline in dark mode is a common offender. Style the focus ring using a colour variable too — preferably one that pushes contrast above 3:1 against the active background.
Check transitions as well. A hard colour swap can trigger migraines for users with vestibular conditions, a group protected under the Disability Discrimination Act. A short transition: background-color 0.2s ease, color 0.2s ease; on the body element smooths the switch without slowing the page down. Disable transitions inside prefers-reduced-motion: reduce so motion-sensitive users get an instant change instead of an animation they cannot opt out of.
Once testing passes locally, run the site through PageSpeed Insights or a Lighthouse audit. Sites hosted in Australia — common choices include servers in Sydney through providers like AWS Asia Pacific (Sydney) or DigitalOcean's Sydney region — typically see Core Web Vitals gains from the absence of a heavy theme-switching library. The custom implementation described here adds under two kilobytes of JavaScript and zero external requests, a profile that fits comfortably within the Commonwealth Procurement Rules' preference for lightweight, sustainable digital services.