Add dark mode to Ghost's Casper theme

Restore dark mode to Ghost's Casper theme with Code Injection; ending with a persistent Light/Dark/Auto toggle that survives flashes, private windows, and OS theme changes.

Casper's built-in dark mode disappeared in Ghost 4.0 and never came back. These recipes restore it, from a one-liner to a full Light/Dark/Auto toggle. Each section is a standalone approach; if you install the toggle in the last section, skip the others.

Force Dark mode on every page

Add the following to Settings > Advanced > Code Injection > Site header:

<script>
  document.documentElement.classList.add('dark-mode');
</script>

Set light or dark mode depending on operating system preference

Add the following to Settings > Advanced > Code Injection > Site header:

<script>
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)
    document.documentElement.classList.add('dark-mode');
</script>

Dynamically update light or dark mode depending on operating system preference

Add the following to Settings > Advanced > Code Injection > Site header:

<script>
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');

  function updateTheme(e) {
    if (e.matches)
      document.documentElement.classList.add('dark-mode');
    else
      document.documentElement.classList.remove('dark-mode');
  }

  updateTheme(prefersDark);
  prefersDark.addEventListener('change', updateTheme);
</script>

Add a toggle switch to pick Light, Dark, or Auto

Step 1: Add the Logic (Site Header)

This script runs instantly, before the page paints, to prevent a bright "flash" if the user has selected dark mode. The key detail is that the stored preference is read from localStorage synchronously, in the same block as the class assignment, not deferred to DOMContentLoaded. Reading it later would reintroduce exactly the flash this script exists to prevent. The read is also wrapped in try/catch, because browsers in private-browsing mode can throw on localStorage access. The storage key (casper-theme-pref) must match the one written by the footer script in Step 2; if they disagree, returning visitors get a flash before the footer script corrects the theme.

  1. Go to Settings > Advanced > Code Injection.
  2. Paste this code into the Site Header block:
<style>
  .theme-toggle-container {
    display: inline-flex;
    align-items: center;
    background: #f4f5f6;
    border: 1px solid #e0e0e0;
    padding: 2px;
    border-radius: 20px;
    margin-right: 10px;
    gap: 1px;
    height: 28px;
    vertical-align: middle;
  }

  html.dark-mode .theme-toggle-container {
    background: #202327;
    border-color: #373a40;
  }

  .theme-toggle-btn {
    background: transparent;
    border: none;
    cursor: pointer;
    font-size: 11px;
    padding: 0 8px;
    height: 22px;
    border-radius: 12px;
    color: #747880;
    transition: all 0.2s ease;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .theme-toggle-btn.active {
    background: #ffffff;
    color: #15171a;
    box-shadow: 0 1px 3px rgba(0,0,0,0.1);
  }

  html.dark-mode .theme-toggle-btn.active {
    background: #373a40;
    color: #ffffff;
  }

  .gh-head-actions-list {
    display: flex !important;
    align-items: center !important;
  }
</style>

<script>
  (function () {
    var saved = null;
    try { saved = localStorage.getItem('casper-theme-pref'); } catch (e) {}

    var dark =
      saved === 'dark' ||
      (saved !== 'light' &&
        window.matchMedia &&
        window.matchMedia('(prefers-color-scheme: dark)').matches);

    if (dark) document.documentElement.classList.add('dark-mode');
  })();
</script>

This block injects the physical button interface onto your site and hooks up the clicking behavior. Each button carries both a title and an aria-label so its purpose is available to screen readers and keyboard focus, not just mouse hover. Emoji alone is announced inconsistently by assistive tech and renders differently across platforms. The active button mirrors its state with aria-pressed, and the group is exposed as a labelled control group (role="group") for keyboard and screen-reader navigation.

  1. Scroll down to the Site Footer block in Code Injection.
  2. Paste the following HTML and JavaScript:
<script>
  document.addEventListener('DOMContentLoaded', () => {
    // 1. Build the HTML block for the toggle element dynamically
    const toggleMarkup = `
      <div class="theme-toggle-container" role="group" aria-label="Theme">
        <button class="theme-toggle-btn" data-theme="light" title="Light Mode" aria-label="Light mode">☀️</button>
        <button class="theme-toggle-btn" data-theme="dark" title="Dark Mode" aria-label="Dark mode">🌙</button>
        <button class="theme-toggle-btn" data-theme="auto" title="Auto (System)" aria-label="Auto (system theme)">💻</button>
      </div>
    `;

    // 2. Locate Casper's native header action layout tree
    const targetMenu = document.querySelector('.gh-head-actions-list') || document.querySelector('.gh-head-actions');

    if (targetMenu) {
      // Injects the capsule right at the beginning of the list, perfectly preceding search
      targetMenu.insertAdjacentHTML('afterbegin', toggleMarkup);
    } else {
      // Safe fallback option if custom headers are toggled
      document.body.insertAdjacentHTML('beforeend', `<div class="theme-toggle-container" style="position:fixed; bottom:20px; right:20px; z-index:9999;">${toggleMarkup}</div>`);
    }

    // 3. Setup core button click handling logic
    const buttons = document.querySelectorAll('.theme-toggle-btn');
    const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');

    var currentTheme = 'auto';
    try { currentTheme = localStorage.getItem('casper-theme-pref') || 'auto'; } catch (e) {}
    applyTheme(currentTheme);
    setActiveButton(currentTheme);

    buttons.forEach(button => {
      button.addEventListener('click', (e) => {
        e.preventDefault();
        const selectedTheme = button.getAttribute('data-theme');
        try { localStorage.setItem('casper-theme-pref', selectedTheme); } catch (err) {}
        applyTheme(selectedTheme);
        setActiveButton(selectedTheme);
      });
    });

    function applyTheme(theme) {
      if (theme === 'dark' || (theme === 'auto' && prefersDarkScheme.matches)) {
        document.documentElement.classList.add('dark-mode');
      } else {
        document.documentElement.classList.remove('dark-mode');
      }
    }

    function setActiveButton(theme) {
      buttons.forEach(btn => {
        if (btn.getAttribute('data-theme') === theme) {
          btn.classList.add('active');
          btn.setAttribute('aria-pressed', 'true');
        } else {
          btn.classList.remove('active');
          btn.setAttribute('aria-pressed', 'false');
        }
      });
    }

    prefersDarkScheme.addEventListener('change', () => {
      var pref = 'auto';
      try { pref = localStorage.getItem('casper-theme-pref') || 'auto'; } catch (e) {}
      if (pref === 'auto') {
        applyTheme('auto');
      }
    });
  });
</script>
  1. Click Save.

Design note: the toggle's CSS uses fixed hex values rather than Casper's CSS variables. That's deliberate, since Casper's variable names aren't a stable public API and change between releases. The tradeoff is that the capsule's colors won't track future Casper accent or palette settings.

Source

Casper dark theme is gone in Ghost 4.0?
Just saw new update and if I check demo there is no dark mode anymore? Why did you guys remove it? It was perfect. Is there any plans to bring it back?