Dark Mode Switcher Shared Partial

Follow the Installation section first!
This component requires additional setup (dependencies, gems, controllers, partials, or libraries) before using these shared partials. Please complete the Installation section on this page first.

1. Copy shared partial files

Download all 1 shared partial files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the dark_mode_switcher/ folder to your app/views/shared/components/ directory.

Copy these files to your app/views/shared/components/dark_mode_switcher/ directory:

2. Usage example

<!-- Segmented system, light, and dark switcher -->
<%= render "shared/components/dark_mode_switcher/dark_mode_switcher" %>

<!-- Single-button click cycle -->
<%= render "shared/components/dark_mode_switcher/dark_mode_switcher",
  variant: "cycle" %>

<!-- Icon-only click cycle with a tooltip -->
<%= render "shared/components/dark_mode_switcher/dark_mode_switcher",
  variant: "cycle_icon" %>

Rendered usage example

Dark Mode Switcher ViewComponent

Follow the Installation section first!
This component requires additional setup (gems, controllers, partials, or libraries) before using this ViewComponent. Please complete the Installation section on this page first.

1. Ensure the gem is installed

gem "view_component", "~> 4.2"

2. Copy component files

Download all 2 component files as a ZIP file.

Download all files (ZIP)

After downloading, unzip and copy the dark_mode_switcher/ folder to your app/components/ directory.

Copy these files to your app/components/dark_mode_switcher/ directory:

3. Usage example

<!-- Segmented system, light, and dark switcher -->
<%= render DarkModeSwitcher::Component.new %>

<!-- Single-button click cycle -->
<%= render DarkModeSwitcher::Component.new(variant: :cycle) %>

<!-- Icon-only click cycle with a tooltip -->
<%= render DarkModeSwitcher::Component.new(variant: :cycle_icon) %>

Rendered usage example

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.

Piece Responsibility
localStorage Stores "light" or "dark" under the theme key. The key is removed for System mode.
theme_controller.js Resolves the preference, updates the root element, paints the controls, and handles keyboard interaction.
Root HTML element Receives the dark class, color-scheme property, and data-theme="light|dark" attribute.
Head script Applies the saved or system palette before CSS paints, preventing a theme flash.
theme:changed Synchronizes other switchers in the same document; the storage event synchronizes other browser tabs.
Theme Builder Runs separately. It changes semantic color tokens inside an isolated preview and does not read or write this preference.

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.

Table of contents

Powered by

Get notified when new components come out