Dark Mode Switcher Rails Component
Let visitors follow their system preference or choose light and dark mode explicitly. The UI is called Dark Mode Switcher; its Stimulus controller stays named theme_controller.js because it owns the page theme rather than one particular button design.
Installation
1. Configure Tailwind dark mode
The controller applies a dark class to the root HTML element. Add this custom variant near the top of your Tailwind CSS entrypoint so dark: utilities follow that class.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
2. Apply the theme before paint
Place this script in <head> before your stylesheets. It reads the same preference as the controller, so returning visitors do not see the light theme flash before dark mode is applied.
<script>
(function() {
const storedMode = localStorage.getItem("theme");
const palette = storedMode === "light" || storedMode === "dark"
? storedMode
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
const root = document.documentElement;
root.classList.toggle("dark", palette === "dark");
root.style.colorScheme = palette;
root.dataset.theme = palette;
})();
</script>
3. Install the Stimulus controller
Add the shared controller below. It supports all three switcher examples, synchronizes multiple instances, follows changes to the operating-system preference, and keeps browser tabs in sync.
import { Controller } from "@hotwired/stimulus";
const STORAGE_KEY = "theme";
const SYSTEM_MODE = "system";
const AVAILABLE_MODES = [SYSTEM_MODE, "light", "dark"];
const MODE_LABELS = { system: "System", light: "Light", dark: "Dark" };
export default class extends Controller {
static classes = ["selected", "unselected"];
static targets = ["option", "picker", "cycleButton", "cycleIcon", "cycleLabel"];
initialize() {
this.colorSchemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.systemPreferenceChanged = this.systemPreferenceChanged.bind(this);
this.themeChanged = this.themeChanged.bind(this);
this.storageChanged = this.storageChanged.bind(this);
// The layout's inline script handles the first paint. This keeps the DOM
// correct as soon as Stimulus initializes on subsequent renders.
this.refresh();
}
connect() {
this.refresh();
if (this.hasPickerTarget) this.pickerTarget.classList.remove("opacity-0");
this.colorSchemeQuery.addEventListener("change", this.systemPreferenceChanged);
window.addEventListener("theme:changed", this.themeChanged);
window.addEventListener("storage", this.storageChanged);
}
disconnect() {
this.colorSchemeQuery.removeEventListener("change", this.systemPreferenceChanged);
window.removeEventListener("theme:changed", this.themeChanged);
window.removeEventListener("storage", this.storageChanged);
}
choose(event) {
this.commit(event.currentTarget.dataset.themeMode);
}
cycle() {
const currentIndex = AVAILABLE_MODES.indexOf(this.mode);
const nextMode = AVAILABLE_MODES[(currentIndex + 1) % AVAILABLE_MODES.length];
this.commit(nextMode);
}
navigate(event) {
const currentIndex = this.optionTargets.indexOf(event.currentTarget);
if (currentIndex === -1) return;
const nextIndex = this.destinationIndex(event.key, currentIndex);
if (nextIndex === null) return;
event.preventDefault();
const nextOption = this.optionTargets[nextIndex];
nextOption.focus();
this.commit(nextOption.dataset.themeMode);
}
commit(mode) {
if (!AVAILABLE_MODES.includes(mode)) return;
this.store(mode);
this.refresh();
this.announce();
}
refresh() {
const mode = this.mode;
const palette = this.resolve(mode);
this.paintDocument(palette);
this.paintOptions(mode);
this.paintCycle(mode);
}
systemPreferenceChanged() {
if (this.mode !== SYSTEM_MODE) return;
this.refresh();
this.announce();
}
themeChanged() {
this.refresh();
}
storageChanged(event) {
if (event.storageArea !== localStorage) return;
if (event.key !== STORAGE_KEY && event.key !== null) return;
this.refresh();
this.announce();
}
destinationIndex(key, currentIndex) {
switch (key) {
case "ArrowLeft":
case "ArrowUp":
return (currentIndex - 1 + this.optionTargets.length) % this.optionTargets.length;
case "ArrowRight":
case "ArrowDown":
return (currentIndex + 1) % this.optionTargets.length;
case "Home":
return 0;
case "End":
return this.optionTargets.length - 1;
default:
return null;
}
}
store(mode) {
if (mode === SYSTEM_MODE) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, mode);
}
}
resolve(mode) {
if (mode !== SYSTEM_MODE) return mode;
return this.colorSchemeQuery.matches ? "dark" : "light";
}
paintDocument(palette) {
const dark = palette === "dark";
const root = document.documentElement;
root.classList.toggle("dark", dark);
root.classList.toggle("sl-theme-dark", dark);
root.style.colorScheme = palette;
root.dataset.theme = palette;
}
paintOptions(mode) {
this.optionTargets.forEach((option) => {
const selected = option.dataset.themeMode === mode;
option.setAttribute("aria-checked", selected.toString());
option.tabIndex = selected ? 0 : -1;
if (selected) {
option.classList.add(...this.selectedClasses);
option.classList.remove(...this.unselectedClasses);
} else {
option.classList.remove(...this.selectedClasses);
option.classList.add(...this.unselectedClasses);
}
});
}
paintCycle(mode) {
this.cycleIconTargets.forEach((icon) => {
icon.toggleAttribute("hidden", icon.dataset.themeMode !== mode);
});
if (this.hasCycleLabelTarget) this.cycleLabelTarget.textContent = MODE_LABELS[mode];
if (!this.hasCycleButtonTarget) return;
const nextIndex = (AVAILABLE_MODES.indexOf(mode) + 1) % AVAILABLE_MODES.length;
const nextMode = AVAILABLE_MODES[nextIndex];
this.cycleButtonTarget.setAttribute(
"aria-label",
`${MODE_LABELS[mode]} theme. Switch to ${MODE_LABELS[nextMode].toLowerCase()} theme`,
);
}
announce() {
const mode = this.mode;
this.dispatch("changed", {
target: window,
detail: {
mode,
palette: this.resolve(mode),
},
});
}
get mode() {
const storedMode = localStorage.getItem(STORAGE_KEY);
return AVAILABLE_MODES.includes(storedMode) ? storedMode : SYSTEM_MODE;
}
}
Optional tooltip setup: the segmented and icon-only examples use the Rails Blocks Tooltip component, which depends on Floating UI. Remove the data-controller="tooltip" attributes if you want the switcher without tooltips.
Examples
System, Light, and Dark
This is the same compact segmented switcher used by Rails Blocks. Click an option, or move through the radiogroup with the arrow keys. Home and End jump to the first and last option.
<div
data-controller="theme"
data-theme-selected-class="bg-white ring-1 ring-neutral-950/10 dark:bg-neutral-700 dark:text-white dark:ring-white/20"
data-theme-unselected-class="hover:bg-neutral-950/5 dark:hover:bg-white/5">
<div
class="relative z-0 inline-grid grid-cols-3 rounded-full border border-black/10 bg-white/80 p-0.5 text-neutral-950 opacity-0 shadow-xs backdrop-blur-md transition-opacity duration-200 dark:border-white/10 dark:bg-neutral-800/80 dark:text-white"
data-theme-target="picker"
role="radiogroup"
aria-label="Theme selection">
<button
class="m-0.5 flex items-center justify-center rounded-full p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-950/20 dark:focus-visible:ring-white/20"
aria-label="System theme"
data-controller="tooltip"
data-tooltip-content="System"
data-tooltip-animation-value="fade origin"
data-tooltip-delay-value="500"
data-theme-target="option"
data-action="click->theme#choose keydown->theme#navigate"
data-theme-mode="system"
type="button"
role="radio"
aria-checked="true"
tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5.75 15.75C6.508 15.511 7.628 15.25 9 15.25C9.795 15.25 10.941 15.338 12.25 15.75"></path><path d="M9 12.75V15.25"></path><path d="M14.25 2.75H3.75C2.64543 2.75 1.75 3.64543 1.75 4.75V10.75C1.75 11.8546 2.64543 12.75 3.75 12.75H14.25C15.3546 12.75 16.25 11.8546 16.25 10.75V4.75C16.25 3.64543 15.3546 2.75 14.25 2.75Z"></path></g></svg>
</button>
<button
class="m-0.5 flex items-center justify-center rounded-full p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-950/20 dark:focus-visible:ring-white/20"
aria-label="Light theme"
data-controller="tooltip"
data-tooltip-content="Light mode"
data-tooltip-animation-value="fade origin"
data-tooltip-delay-value="500"
data-theme-target="option"
data-action="click->theme#choose keydown->theme#navigate"
data-theme-mode="light"
type="button"
role="radio"
aria-checked="false"
tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="9" y1="1.25" x2="9" y2="2.25"></line><line x1="14.48" y1="3.52" x2="13.773" y2="4.227"></line><line x1="16.75" y1="9" x2="15.75" y2="9"></line><line x1="14.48" y1="14.48" x2="13.773" y2="13.773"></line><line x1="9" y1="16.75" x2="9" y2="15.75"></line><line x1="3.52" y1="14.48" x2="4.227" y2="13.773"></line><line x1="1.25" y1="9" x2="2.25" y2="9"></line><line x1="3.52" y1="3.52" x2="4.227" y2="4.227"></line><circle cx="9" cy="9" r="4.25"></circle></g></svg>
</button>
<button
class="m-0.5 flex items-center justify-center rounded-full p-2 outline-hidden focus-visible:ring-2 focus-visible:ring-neutral-950/20 dark:focus-visible:ring-white/20"
aria-label="Dark theme"
data-controller="tooltip"
data-tooltip-content="Dark mode"
data-tooltip-animation-value="fade origin"
data-tooltip-delay-value="500"
data-theme-target="option"
data-action="click->theme#choose keydown->theme#navigate"
data-theme-mode="dark"
type="button"
role="radio"
aria-checked="false"
tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13,11.75c-3.452,0-6.25-2.798-6.25-6.25,0-1.352,.433-2.599,1.162-3.622-3.364,.628-5.912,3.575-5.912,7.122,0,4.004,3.246,7.25,7.25,7.25,3.372,0,6.198-2.306,7.009-5.424-.95,.583-2.063,.924-3.259,.924Z"></path><path d="M12.743,4.492l-.946-.315-.316-.947c-.102-.306-.609-.306-.711,0l-.316,.947-.946,.315c-.153,.051-.257,.194-.257,.356s.104,.305,.257,.356l.946,.315,.316,.947c.051,.153,.194,.256,.355,.256s.305-.104,.355-.256l.316-.947,.946-.315c.153-.051,.257-.194,.257-.356s-.104-.305-.257-.356Z" fill="currentColor" stroke="none"></path><circle cx="14.25" cy="7.75" r=".75" fill="currentColor" stroke="none"></circle></g></svg>
</button>
</div>
</div>
Click Cycle
A single native button cycles through System, Light, and Dark. Its icon, visible label, and accessible label update after every click.
<div data-controller="theme">
<button
type="button"
class="inline-flex min-w-28 items-center justify-center gap-2 rounded-full border border-black/10 bg-white/80 px-4 py-2 text-sm font-medium text-neutral-700 opacity-0 shadow-xs backdrop-blur-md transition hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:border-white/10 dark:bg-neutral-800/80 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus-visible:outline-neutral-300"
data-theme-target="picker cycleButton"
data-action="theme#cycle"
aria-label="System theme. Switch to light theme">
<svg data-theme-target="cycleIcon" data-theme-mode="system" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5.75 15.75C6.508 15.511 7.628 15.25 9 15.25C9.795 15.25 10.941 15.338 12.25 15.75"></path><path d="M9 12.75V15.25"></path><path d="M14.25 2.75H3.75C2.64543 2.75 1.75 3.64543 1.75 4.75V10.75C1.75 11.8546 2.64543 12.75 3.75 12.75H14.25C15.3546 12.75 16.25 11.8546 16.25 10.75V4.75C16.25 3.64543 15.3546 2.75 14.25 2.75Z"></path></g></svg>
<svg hidden data-theme-target="cycleIcon" data-theme-mode="light" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="9" y1="1.25" x2="9" y2="2.25"></line><line x1="14.48" y1="3.52" x2="13.773" y2="4.227"></line><line x1="16.75" y1="9" x2="15.75" y2="9"></line><line x1="14.48" y1="14.48" x2="13.773" y2="13.773"></line><line x1="9" y1="16.75" x2="9" y2="15.75"></line><line x1="3.52" y1="14.48" x2="4.227" y2="13.773"></line><line x1="1.25" y1="9" x2="2.25" y2="9"></line><line x1="3.52" y1="3.52" x2="4.227" y2="4.227"></line><circle cx="9" cy="9" r="4.25"></circle></g></svg>
<svg hidden data-theme-target="cycleIcon" data-theme-mode="dark" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13,11.75c-3.452,0-6.25-2.798-6.25-6.25,0-1.352,.433-2.599,1.162-3.622-3.364,.628-5.912,3.575-5.912,7.122,0,4.004,3.246,7.25,7.25,7.25,3.372,0,6.198-2.306,7.009-5.424-.95,.583-2.063,.924-3.259,.924Z"></path><path d="M12.743,4.492l-.946-.315-.316-.947c-.102-.306-.609-.306-.711,0l-.316,.947-.946,.315c-.153,.051-.257,.194-.257,.356s.104,.305,.257,.356l.946,.315,.316,.947c.051,.153,.194,.256,.355,.256s.305-.104,.355-.256l.316-.947,.946-.315c.153-.051,.257-.194,.257-.356s-.104-.305-.257-.356Z" fill="currentColor" stroke="none"></path><circle cx="14.25" cy="7.75" r=".75" fill="currentColor" stroke="none"></circle></g></svg>
<span data-theme-target="cycleLabel">System</span>
</button>
</div>
Icon-only Click Cycle
The same cycle behavior in a compact icon button. Its tooltip identifies the action, while its accessible label announces both the current mode and the mode that comes next.
<div data-controller="theme">
<button
type="button"
class="inline-flex size-9 items-center justify-center rounded-full border border-black/10 bg-white/80 text-neutral-700 opacity-0 shadow-xs backdrop-blur-md transition hover:bg-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 dark:border-white/10 dark:bg-neutral-800/80 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus-visible:outline-neutral-300"
data-controller="tooltip"
data-tooltip-content="Change theme"
data-tooltip-animation-value="fade origin"
data-tooltip-delay-value="500"
data-theme-target="picker cycleButton"
data-action="theme#cycle"
aria-label="System theme. Switch to light theme">
<svg data-theme-target="cycleIcon" data-theme-mode="system" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M5.75 15.75C6.508 15.511 7.628 15.25 9 15.25C9.795 15.25 10.941 15.338 12.25 15.75"></path><path d="M9 12.75V15.25"></path><path d="M14.25 2.75H3.75C2.64543 2.75 1.75 3.64543 1.75 4.75V10.75C1.75 11.8546 2.64543 12.75 3.75 12.75H14.25C15.3546 12.75 16.25 11.8546 16.25 10.75V4.75C16.25 3.64543 15.3546 2.75 14.25 2.75Z"></path></g></svg>
<svg hidden data-theme-target="cycleIcon" data-theme-mode="light" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><line x1="9" y1="1.25" x2="9" y2="2.25"></line><line x1="14.48" y1="3.52" x2="13.773" y2="4.227"></line><line x1="16.75" y1="9" x2="15.75" y2="9"></line><line x1="14.48" y1="14.48" x2="13.773" y2="13.773"></line><line x1="9" y1="16.75" x2="9" y2="15.75"></line><line x1="3.52" y1="14.48" x2="4.227" y2="13.773"></line><line x1="1.25" y1="9" x2="2.25" y2="9"></line><line x1="3.52" y1="3.52" x2="4.227" y2="4.227"></line><circle cx="9" cy="9" r="4.25"></circle></g></svg>
<svg hidden data-theme-target="cycleIcon" data-theme-mode="dark" xmlns="http://www.w3.org/2000/svg" class="size-4" width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" stroke="currentColor"><path d="M13,11.75c-3.452,0-6.25-2.798-6.25-6.25,0-1.352,.433-2.599,1.162-3.622-3.364,.628-5.912,3.575-5.912,7.122,0,4.004,3.246,7.25,7.25,7.25,3.372,0,6.198-2.306,7.009-5.424-.95,.583-2.063,.924-3.259,.924Z"></path><path d="M12.743,4.492l-.946-.315-.316-.947c-.102-.306-.609-.306-.711,0l-.316,.947-.946,.315c-.153,.051-.257,.194-.257,.356s.104,.305,.257,.356l.946,.315,.316,.947c.051,.153,.194,.256,.355,.256s.305-.104,.355-.256l.316-.947,.946-.315c.153-.051,.257-.194,.257-.356s-.104-.305-.257-.356Z" fill="currentColor" stroke="none"></path><circle cx="14.25" cy="7.75" r=".75" fill="currentColor" stroke="none"></circle></g></svg>
</button>
</div>
How it works
All three examples are presentations of the same preference. They can appear together—or alongside another theme controller—and remain synchronized through a window event.
Modes and resolved palette
System is a preference mode, not a third visual palette. The controller resolves it to light or dark with prefers-color-scheme. This switcher intentionally handles those two palettes only; custom accent and base themes belong to the separate Theme Builder and semantic CSS-token layer.