Editorial cover graphic with theme switching UX motif in gold on cream.

Releasing the theme-flip module: simple dark mode, complex decisions

A module for client-side theme switching. Why I released it, what I learned building it, and what I still don't know.

The friction

I was building a Drupal site for a university where accessibility was non-negotiable. Part of that is respecting the user’s colour scheme preference — light mode, dark mode, or whatever their browser is set to. But the university’s visual identity needed some adjustment between the two, and a simple CSS variable flip was not enough. The light theme needed warmer contrasts; the dark theme needed a different set of highlights. The user should be able to switch between them at runtime.

I opened the Drupal module ecosystem and found libraries for light-dark mode switches. Most were built for JavaScript frameworks. One was built for Drupal but required jQuery and had not been updated since 2019. Every solution I found was heavier than the problem.

Why existing patterns didn’t fit

The obvious path was to use the browser’s native prefers-color-scheme media query. Set up CSS for light and dark, respect the system preference, done. But this falls apart when the user wants a choice. Their browser is set to dark mode, but they want light mode on this site. There is no standard way to override the system preference without a dedicated toggle.

There are third-party libraries like next-themes for React and similar packages for other frameworks. They do the right thing — check localStorage, set a class on the document, let CSS respond. But adding a framework-specific package to a Drupal site just to toggle a theme felt wrong. Drupal already has a way to do this. I just needed to find it.

I looked at the Drupal module I mentioned above. It worked, but it was built for a different era of JavaScript. It had dependencies that were outdated. It did not handle high-contrast mode or reduced-motion preferences. It was the closest thing in contrib, but it was not what I needed.

What got built

I wrote a module. The whole thing is about 100 lines of code spread across four files: a module file that registers assets, a JavaScript file that handles the toggle, a CSS file for the toggle button styling, and a theme template that renders the button.

The JavaScript checks localStorage for a theme preference on page load. If one exists, it applies that preference before the page paints — so no flash of unstyled content. If not, it respects the system preference via prefers-color-scheme. When the user clicks the toggle, JavaScript saves the preference and reapplies the theme class.

The key insight was that this does not need to be clever. It needs to be boring, resilient, and invisible when it works. The theme systems themselves — the actual CSS variables and colour definitions — are still the site’s responsibility. The module just orchestrates the switching.

js/theme-flip.js
(function() {
// Run before page paint to prevent flash
const stored = localStorage.getItem('theme-preference');
const system = window.matchMedia('(prefers-color-scheme: light)').matches;
const theme = stored || (system ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
})();

That is the core. The toggle button is just an event listener that reads the current theme, switches to the other one, and saves the preference.

Checking the community

Before I released it, I searched the issue queue for similar problems. I found three issues asking for theme switching out of the box. One had a workaround using a custom module. Another had a module recommendation that was out of date. The third was closed as “not core’s responsibility.”

I searched the forums and found people asking the same thing in different ways: how do I let users switch themes? Some were trying to use Layout Builder theming. Some were trying permission-based theme negotiation. Others had given up and used a third-party JavaScript library.

The pattern was clear enough: people wanted this, they were not finding a good solution in contrib, and they were building workarounds or importing external code.

Generalising it

The tricky part was making the module not assume anything about how your site’s themes are structured. It does not assume you have a light theme and a dark theme. It does not assume you are using CSS variables. It just provides a toggle button and a data attribute on the root element. Your themes respond to that attribute however they need to.

To make it work, a site has to:

  1. Define two theme names (or IDs, or mode names).
  2. Write CSS that responds to [data-theme="dark"] or similar.
  3. Enable the module.

That is the entire contract. A site could use this with Drupal’s core themes, with contrib themes, or with custom themes. It does not care.

The one thing I had to decide was where the toggle button lives. It has to appear before the user can click it, which means it cannot be lazy-loaded. It cannot be part of a render array. It has to be in the document from the start. I settled on making it a configurable element that the module inserts into the page header via a Drupal setting. Sites can move it or override the template if they need it elsewhere.

How to use it

settings.local.php
$settings['theme_flip.enabled'] = TRUE;
$settings['theme_flip.default_theme'] = 'light';
$settings['theme_flip.themes'] = ['light', 'dark'];

The module handles the rest. It injects a toggle button and manages the localStorage key. Themes respond to the data attribute.

I spent more time writing documentation than writing code. The module is simple, but the decision space around when and how to use it is large. I wrote guides for implementing it with CSS variables, with separate theme files, with preprocessor configuration, with JavaScript that runs after page load.

What I haven’t solved

The module assumes you want two themes. Real sites sometimes want three (light, dark, high-contrast). That is a straightforward extension, but it requires rethinking the toggle — a button works for two states, but three needs a selector.

The module also does not handle theme switching for logged-in users who have a user preference stored in the database. Some universities want to remember a user’s choice across sessions. The module respects localStorage for current-session preference, but it does not read or write the user entity.

The last thing I am genuinely uncertain about: should the toggle be visible only if JavaScript succeeds? Right now it is always in the HTML, and it is hidden with CSS if JavaScript did not run. This is defensive, but it means a user with scripts disabled sees a button that does not work. A more honest approach would be to inject the button only when JavaScript confirms it will work, but that adds complexity and a brief moment without the toggle visible.

I released the module as-is because it solves the core problem credibly. But I am curious what patterns multisite implementations find when they try to extend it. If you use it and hit a wall, I would genuinely like to know what it was.