Add dark mode to Ghost's Source theme
A Light/Dark/Auto toggle for Ghost's Source theme through Code Injection. No theme edits, no flashing reload.
I haven't used Ghost in a while, so when I started this blog, I rolled the theme back to Casper since I was more familiar with it. After adding a dark mode toggle, I attempted code block syntax highlighting and ran into nothing but issues. I decided to give the new Source theme a shot.
My Casper post restored the light and dark mode that Ghost removed in Casper 4.0. The secret there was that Casper still had dark mode styles buried in its CSS. All the toggle did was apply a class. Source has no dark mode at all. No .dark-mode stylesheet, no hidden class to flip. Nothing.
What Source does have is something arguably better: its colors are defined as CSS custom properties (design tokens). This means dark mode is just a matter of overriding a handful of variables. The catch, and the reason this post spends real time on process, is that those token names are an internal detail that can shift between theme versions. I'll show you how to find the ones your build uses. The recipe below is verified on Source with the Search header style as of this writing. That syntax highlighting struggle from the first paragraph got its own post; if you want code blocks with highlighting, line numbers, and a copy button, that's where to start.
Each section is standalone. If you install the toggle in the last section, skip the others.
Force Dark mode on every page
Unlike Casper, flipping a class alone does nothing here. We also have to supply the dark palette. Add the following to Settings > Advanced > Code Injection > Site header:
<style>
html.dark-mode {
--background-color: #15171a;
--color-darker-gray: #e8eaed;
--color-secondary-text: #a8aeb4;
}
html.dark-mode body {
background-color: #15171a;
color: #e8eaed;
}
</style>
<script>
document.documentElement.classList.add('dark-mode');
</script>
Three tokens and one body rule. --background-color paints the page and the navigation bar, --color-darker-gray carries primary text (headings, nav links, card titles), and --color-secondary-text handles the muted stuff, post meta, author names, dates.
One thing deliberately absent: color-scheme: dark. You'll see it recommended in most dark-mode guides, and it works as advertised for native scrollbars and form controls. But it also makes browsers paint iframes whose color scheme doesn't match yours with an opaque canvas. On this theme that breaks the translucent backdrop behind the search modal in dark mode. See the Known limitations section for the details. The short version: leave it out.
Set light or dark mode depending on operating system preference
Swap the <script> above for this one (keep the <style>):
<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
Same deal. This version reacts live if the visitor switches their OS theme while your site is open:
<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
Finding your build's tokens
Before the toggle, a short detour that will save you confusion later. The token names above are what my Source build uses. Yours may differ, especially after a theme update. When something refuses to go dark, this is the drill:
- Inspect the element in DevTools (right-click → Inspect).
- Find the rule setting its
colororbackground-color. It will typically readcolor: var(--something). - That's your token. Add it to the
html.dark-modeblock with a dark-appropriate value.
That's genuinely the whole debugging process. I hit it three separate times while building this: the navigation bar needed --background-color, the post meta needed --color-secondary-text, and one search through DevTools each time resolved it in under a minute. Worth knowing, because this theme's variable names aren't a stable public API. Treat them like Casper's markup, subject to change between releases.
One sanity check on your color choices while you're at it: #a8aeb4 secondary text on #15171a sits around 7:1 contrast, comfortably clearing WCAG AA. Dark mode shouldn't mean invisible mode.
Step 1: Add the Logic and Palette (Site Header)
This 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 wrapped in try/catch, because browsers in private-browsing mode can throw on localStorage access. The storage key (source-theme-pref) must match the one written by the footer script in Step 2.
- Go to Settings > Advanced > Code Injection.
- Paste this code into the Site Header block:
<!-- Applies a dark color scheme under html.dark-mode and
styles the three-button theme toggle capsule (light/dark/auto). -->
<style>
/* Dark mode base palette */
html.dark-mode {
--background-color: #15171a;
--color-darker-gray: #e8eaed;
--color-secondary-text: #a8aeb4;
}
html.dark-mode body {
background-color: #15171a;
color: #e8eaed;
}
html.dark-mode .gh-content hr {
background-color: rgba(255, 255, 255, 0.12) !important;
}
/* Dark mode borders on tables, rules, captions, and footer */
html.dark-mode td,
html.dark-mode th,
html.dark-mode hr,
html.dark-mode .gh-container-title,
html.dark-mode .gh-footer-bar,
html.dark-mode figure figcaption {
border-color: rgba(255, 255, 255, 0.12) !important;
}
/* Feed separators between post cards */
html.dark-mode .gh-card,
html.dark-mode .gh-feed article,
html.dark-mode .gh-feed > * {
border-color: rgba(255, 255, 255, 0.12) !important;
}
html.dark-mode .gh-card::before,
html.dark-mode .gh-card::after,
html.dark-mode .gh-feed article::before,
html.dark-mode .gh-feed article::after {
background-color: rgba(255, 255, 255, 0.12) !important;
}
/* Theme toggle capsule (in-nav placement) */
.theme-toggle-container {
display: inline-flex;
align-items: center;
background: #f4f5f6;
border: 1px solid #e0e0e0;
padding: 2px;
border-radius: 20px;
margin-right: 6px;
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-navigation-actions {
display: flex !important;
align-items: center !important;
}
/* Floating capsule variant: used when the capsule is parented to <body>,
i.e. on small screens or when the nav anchor element is missing. */
.theme-toggle-container.floating {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
}
</style>
<!-- Runs before first paint.
Reads the saved preference (or the OS setting if none) and
adds .dark-mode to <html>, preventing a light-mode flash. -->
<script>
(function () {
var saved = null;
try { saved = localStorage.getItem('source-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>
Note the injection target: Source's header is built around .gh-navigation-actions, the container holding the search icon and member links, so the capsule lands immediately before the search button.
Step 2: Add the Visual Toggle (Site Footer)
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. The active button mirrors its state with aria-pressed, and the group is exposed as a labelled control group.
- Select the Site Footer tab in Code Injection.
- Paste the following JavaScript:
<!-- Builds the theme capsule, mounts it in the nav (desktop) or as a floating element (mobile / missing nav),
persists the choice to localStorage, and reacts to OS theme changes while in Auto mode. -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const STORAGE_KEY = 'source-theme-pref';
/* Build the capsule and attach it to <body> temporarily */
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>
`;
document.body.insertAdjacentHTML('beforeend', toggleMarkup);
const container = document.querySelector('.theme-toggle-container');
/* Desktop: front of the nav actions container.
Below 767px (or if the nav is absent): floating on <body>,
escaping the nav's stacking context and mobile layout collisions. */
function mountCapsule(isMobile) {
const navTarget = document.querySelector('.gh-navigation-actions')
|| document.querySelector('.gh-navigation-inner');
if (isMobile || !navTarget) {
container.classList.add('floating');
document.body.appendChild(container);
} else {
container.classList.remove('floating');
navTarget.insertAdjacentElement('afterbegin', container);
}
}
const mobileQuery = window.matchMedia('(max-width: 767px)');
mountCapsule(mobileQuery.matches);
mobileQuery.addEventListener('change', e => mountCapsule(e.matches));
/* Theme logic */
const buttons = container.querySelectorAll('.theme-toggle-btn');
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
function storedPreference(fallback) {
try { return localStorage.getItem(STORAGE_KEY) || fallback; } catch (e) { return fallback; }
}
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 => {
const active = btn.dataset.theme === theme;
btn.classList.toggle('active', active);
btn.setAttribute('aria-pressed', active);
});
}
const currentTheme = storedPreference('auto');
applyTheme(currentTheme);
setActiveButton(currentTheme);
buttons.forEach(button => {
button.addEventListener('click', () => {
const selected = button.dataset.theme;
try { localStorage.setItem(STORAGE_KEY, selected); } catch (e) {}
applyTheme(selected);
setActiveButton(selected);
});
});
/* Live-update if the OS theme changes while the visitor is on Auto */
prefersDarkScheme.addEventListener('change', () => {
if (storedPreference('auto') === 'auto') applyTheme('auto');
});
});
</script>
- Click Save.
Why the toggle moves on mobile
The capsule doesn't just shrink for phones. It moves, and the reason took me three debugging rounds to pin down, so it's worth documenting.
The first symptom was invisible: on small screens the capsule didn't appear at all. DevTools showed it in the DOM, correctly sized, but inheriting visibility: hidden from .gh-navigation-actions, which Source hides in its mobile layout. Forcing it visible fixed that, only to reveal the second problem: the navigation logo painted over it. And fixing that with z-index revealed the third: the capsule couldn't rise above page content no matter how large its z-index, because the nav creates a stacking context. An element's z-index only competes inside the nearest stacking context it's trapped in, and everything inside the nav loses to content outside it.
The clean escape is the mountCapsule function in Step 2. Below 767px, the capsule is re-parented to document.body. Physically outside the nav, it escapes the hidden container, the logo collision, and the stacking context in one move, and the .floating class pins it to the bottom-right corner. Rotate a tablet or resize a window across the breakpoint and it relocates live, so nothing breaks at the seam.
This is why the mounting decision lives in JavaScript rather than a CSS media query. CSS can restyle an element at any viewport width, but it can't re-parent one. The stacking context only gives up its prisoners to a script.
Known Limitations
- The search modal stays light. Ghost's search UI (
sodo-search) runs in a platform-managed iframe with its own styling. Restyling it means reaching inside the same-origin iframe and fighting both Tailwind utility classes and periodic document rebuilds. I left it light for now. - Portal's login/subscribe popups share the search modal's iframe architecture and the same limitation.
- Embedded cards (audio, bookmarks, and friends) are a known Ghost quirk. Some versions don't fully inherit theme dark modes there. Check a test post with embedded cards before publishing.
A note on translucency: you might have seen other dark-mode guides recommend adding color-scheme: dark to your palette. Skip it here. When your page declares one color scheme and the iframe another, browsers paint the iframe's canvas opaque to preserve contrast. That breaks the translucent backdrop behind the search modal in dark mode. Omitting it keeps the frosted-glass effect intact while the modal itself remains light-styled.
Coming from Casper?
If you used my Casper toggle, change the storage key from source-theme-pref to casper-theme-pref in both scripts (it must match in both, or returning visitors get a flash before the footer script corrects the theme) and your readers keep their saved preference across the theme switch. Note the token families differ between the themes: Casper-era variables like --secondary-text-color correspond to Source's --color-secondary-text.
Design note: the toggle's CSS uses fixed hex values rather than Source's variables, deliberately. The same reasoning as the Casper post. Variable names aren't a stable public API and change between releases. The tradeoff is the capsule's colors won't track future Source accent or palette settings.