How to Add Dark Mode to Your Jekyll Site
Add a dark mode toggle to any Jekyll site — using CSS variables, a JavaScript toggle, and localStorage to remember the user's preference.
Dark mode is no longer a novelty feature — it is an expectation. Roughly 80% of smartphone users operate their devices in dark mode at least some of the time, and OS-level dark mode preferences are now standard across macOS, Windows, iOS, and Android. If your Jekyll site does not respect those preferences, it will flash bright white in someone’s face when they open it at night.
The good news is that adding dark mode to a Jekyll site is entirely a front-end problem. Jekyll builds static HTML — there is no server, no session state, no user accounts. Everything happens in CSS and JavaScript. This guide covers the complete implementation: CSS custom properties for instant colour switching, respecting the operating system preference on first visit, a toggle button to let users override the OS setting, and localStorage to remember their choice across sessions.
The architecture
Four pieces work together:
CSS custom properties (variables) hold every colour in the theme. Switching from light to dark means changing the values of those variables on the root element — all colours update at once with no additional CSS rules.
A data-theme attribute on <html> acts as the CSS hook. [data-theme="dark"] overrides the variable values with dark-mode colours.
A prefers-color-scheme media query sets the initial theme based on the OS setting before any JavaScript runs.
JavaScript with localStorage reads the user’s stored preference, applies it before the page renders (preventing a flash), and handles the toggle button.
Step 1: Define CSS variables
Restructure your stylesheet to use CSS custom properties for every colour. Define them on :root (light mode defaults) and override them for [data-theme="dark"]:
/* _sass/_variables.scss */
:root {
/* Backgrounds */
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-code: #f3f4f6;
/* Text */
--text-primary: #1a1a1a;
--text-secondary: #4b5563;
--text-muted: #6b7280;
/* UI */
--border-color: #e5e7eb;
--link-color: #2563eb;
--link-hover: #1d4ed8;
--heading-color: #111827;
/* Components */
--card-bg: #f9fafb;
--card-border: #e5e7eb;
--tag-bg: #eff6ff;
--tag-text: #1d4ed8;
--nav-bg: #ffffff;
--nav-border: #e5e7eb;
/* Syntax highlighting (light) */
--syntax-bg: #f8f8f8;
--syntax-text: #333333;
}
[data-theme="dark"] {
/* Backgrounds */
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-code: #1e293b;
/* Text */
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
/* UI */
--border-color: #334155;
--link-color: #60a5fa;
--link-hover: #93c5fd;
--heading-color: #f1f5f9;
/* Components */
--card-bg: #1e293b;
--card-border: #334155;
--tag-bg: #1e3a5f;
--tag-text: #93c5fd;
--nav-bg: #0f172a;
--nav-border: #334155;
/* Syntax highlighting (dark) */
--syntax-bg: #1e293b;
--syntax-text: #e2e8f0;
}
Now replace every hard-coded colour throughout your stylesheets with the corresponding variable:
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
h1, h2, h3, h4, h5, h6 {
color: var(--heading-color);
}
a {
color: var(--link-color);
&:hover { color: var(--link-hover); }
}
.card {
background: var(--card-bg);
border: 1px solid var(--card-border);
}
Step 2: Respect the system preference
Users who have enabled dark mode at the OS level expect sites to default to dark on first visit. The prefers-color-scheme media query handles this:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
/* ... all dark mode variables ... */
}
}
The :not([data-theme="light"]) selector is the key detail. It means: “apply dark colours unless the user has explicitly chosen light mode via the toggle.” Without it, a user who switches to light mode via your toggle would see dark mode forced back on them by the media query on the next page load.
Step 3: Add the toggle button
In _includes/header.html, add a toggle button. Use an accessible label and an icon that communicates the current state:
<button
id="theme-toggle"
class="theme-toggle"
aria-label="Switch to dark mode"
title="Toggle dark mode">
<svg class="theme-toggle__sun" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 1 0-14 7 7 0 0 1 0 14zm0-17V.5m0 23v-1.5M4.22 4.22l-1.06-1.06m17.68 17.68-1.06-1.06M1 12h1.5m20 0H24m-4.22-7.78 1.06-1.06M4.22 19.78l-1.06 1.06"/>
</svg>
<svg class="theme-toggle__moon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
Style the button and control which icon is visible:
.theme-toggle {
background: none;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
color: var(--text-secondary);
display: flex;
align-items: center;
transition: border-color 0.2s, color 0.2s;
&:hover {
border-color: var(--link-color);
color: var(--link-color);
}
svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
}
/* In dark mode: show sun icon (to switch to light) */
[data-theme="dark"] .theme-toggle__moon { display: none; }
[data-theme="dark"] .theme-toggle__sun { display: block; }
/* In light mode: show moon icon (to switch to dark) */
.theme-toggle__sun { display: none; }
.theme-toggle__moon { display: block; }
Step 4: Write the JavaScript
The JavaScript does three things: reads the stored theme preference, applies it before the page renders to prevent a flash, and wires up the toggle button. Put this in assets/js/theme-toggle.js:
(function () {
'use strict';
const STORAGE_KEY = 'theme';
const DARK = 'dark';
const LIGHT = 'light';
function getStoredTheme() {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (e) {
return null; // localStorage blocked (private browsing on some browsers)
}
}
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? DARK : LIGHT;
}
function getCurrentTheme() {
return getStoredTheme() || getSystemTheme();
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
function updateToggleLabel(theme) {
const button = document.getElementById('theme-toggle');
if (!button) return;
button.setAttribute(
'aria-label',
theme === DARK ? 'Switch to light mode' : 'Switch to dark mode'
);
}
// Apply theme IMMEDIATELY — before the rest of the page renders
applyTheme(getCurrentTheme());
// Wire up the toggle after the DOM loads
document.addEventListener('DOMContentLoaded', function () {
const button = document.getElementById('theme-toggle');
if (!button) return;
updateToggleLabel(getCurrentTheme());
button.addEventListener('click', function () {
const current = document.documentElement.getAttribute('data-theme');
const next = current === DARK ? LIGHT : DARK;
applyTheme(next);
updateToggleLabel(next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch (e) {
// localStorage unavailable — theme still switches for this session
}
});
// Update icon when OS theme changes (user changes system preference while tab is open)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
if (!getStoredTheme()) {
const theme = e.matches ? DARK : LIGHT;
applyTheme(theme);
updateToggleLabel(theme);
}
});
});
})();
Load this script in <head> — not at the bottom of <body>, not defered. It must run before the page paints to prevent a white flash in dark mode:
<!-- _layouts/default.html, inside <head> -->
<script src="{{ '/assets/js/theme-toggle.js' | relative_url }}"></script>
The script is small (under 1KB minified) so the inline <head> load is acceptable.
Step 5: Dark mode for syntax highlighting
Jekyll uses Rouge for code syntax highlighting. The default light-mode theme (github, pastie, or tango) looks poor on dark backgrounds. You need a second set of highlight colours for dark mode.
Generate a dark Rouge theme and save it to _sass/_syntax-dark.scss:
rougify style monokai > /tmp/monokai.css
Then scope it to dark mode in your Sass:
/* _sass/_syntax-dark.scss */
[data-theme="dark"] {
.highlight {
background: #272822;
color: #f8f8f2;
}
.highlight .k,
.highlight .kd,
.highlight .kn { color: #66d9ef; } /* keywords */
.highlight .s,
.highlight .s1,
.highlight .s2 { color: #a6e22e; } /* strings */
.highlight .c,
.highlight .c1 { color: #75715e; } /* comments */
.highlight .n { color: #f8f8f2; } /* names */
.highlight .o { color: #f92672; } /* operators */
.highlight .mi { color: #ae81ff; } /* integers */
}
Step 6: Images and dark mode
Light images (white backgrounds, light logos) can look jarring in dark mode. Several strategies help:
Reduce opacity slightly to soften images without replacing them:
[data-theme="dark"] img:not([src*=".svg"]) {
opacity: 0.88;
filter: brightness(0.95);
}
Use SVGs with currentColor for icons and logos. SVG paths that use currentColor automatically pick up the text colour from your CSS variables, adapting seamlessly without any JavaScript.
Use the <picture> element for images where you have both light and dark versions:
<picture>
<source srcset="/assets/images/logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/assets/images/logo-light.svg" alt="Site logo">
</picture>
Testing the implementation
Work through these checks:
Toggle works: Click the button — the theme switches. Click again — it switches back.
Preference persists: Enable dark mode. Refresh the page. Dark mode should still be active.
No flash on load: In dark mode, hard-refresh the page. There should be no white flash before dark mode applies. If there is, check that the theme-toggle script is in <head> and not deferred.
OS preference respected: Open an incognito window (no localStorage). Set your OS to dark mode — the site should default to dark.
OS preference overridable: With OS in dark mode, click the toggle to switch to light. Refresh — should still be light (localStorage takes priority over OS preference).
aria-label updates: Inspect the toggle button after switching. The aria-label should say “Switch to light mode” when in dark mode and vice versa.
Colour contrast in dark mode
Dark mode does not mean low contrast. Your dark palette needs to maintain WCAG AA contrast ratios just as the light palette does. The most common mistake is making text too grey: #94a3b8 on #0f172a has a contrast ratio of about 5.5:1, which passes. #64748b on #0f172a drops to about 3.5:1, which fails for body text.
Check your dark palette using the WebAIM Contrast Checker or similar tools. Every text/background pair should be checked.
Many Jekyll themes now include dark mode as a built-in feature. If you would rather start with a theme that handles this for you, browse JekyllHub themes and filter by “dark mode” — Chirpy and Hydejack are particularly well-implemented examples.
Dark mode for syntax highlighting in detail
Code blocks deserve careful attention in dark mode. The default Jekyll/Rouge syntax themes (github, pastie, friendly) are designed for light backgrounds, and they look terrible reversed. You need a purpose-built dark palette.
Several well-regarded dark themes are available:
Monokai — the classic. High-contrast palette from Sublime Text. Green strings, orange keywords, purple types.
Tomorrow Night — softer contrast than Monokai, easier for long reading sessions.
One Dark — the Atom editor palette. Very popular and well-balanced.
You can generate any Rouge theme’s CSS with:
# See all available themes
rougify help style
# Generate a theme's CSS
rougify style monokai > _sass/_syntax-monokai.scss
Then scope it to dark mode and import it in your main Sass file:
/* _sass/_syntax.scss */
/* Light mode syntax (default) */
@import "rouge-github";
/* Dark mode syntax override */
[data-theme="dark"] {
.highlight {
background: #272822;
border-color: #383830;
}
@import "rouge-monokai";
}
This gives you a smooth transition between syntax themes that matches your overall dark/light toggle.
Handling third-party widgets in dark mode
Embedded content — Disqus comments, social share buttons, embedded GitHub gists — has its own colour schemes that do not respond to your CSS variables. Several strategies help:
Disqus supports a dark colour scheme. Pass disqus_config.color_scheme = 'dark' based on your data-theme attribute:
document.addEventListener('DOMContentLoaded', function () {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var disqus_config = function () {
this.page.url = window.location.href;
this.page.identifier = document.body.dataset.postId;
this.theme = isDark ? 'dark' : 'light';
};
// ... load Disqus script
});
GitHub gists do not have a dark mode API, but you can apply a CSS invert filter specifically to gist iframes, which produces a reasonable approximation:
[data-theme="dark"] iframe.gist-iframe {
filter: invert(0.9) hue-rotate(180deg);
}
YouTube embeds support dark mode natively if you add &theme=dark&color=white to the embed URL.
Performance considerations
Dark mode should not cost you any Lighthouse points. A few things to be aware of:
The theme-toggle script blocks render (intentionally, to prevent flash). Keep it tiny — under 1KB minified. Avoid loading it from an external CDN; inline it or serve it from your own domain to avoid an extra network round trip.
CSS transitions on colour changes improve the feel but add a tiny bit of jank on first toggle if there are many elements transitioning simultaneously. Add transitions only to specific properties, not all:
body,
.card,
.site-header,
.site-footer {
transition: background-color 0.2s ease,
color 0.2s ease,
border-color 0.2s ease;
}
Avoid re-painting large areas on toggle. If your dark mode implementation causes a full-page repaint every time it switches, check for properties like box-shadow or filter in your transition list — these are expensive to animate.
Making dark mode accessible
Dark mode is sometimes assumed to automatically improve accessibility, but this is not always true. A few checks to run on your dark palette:
Contrast ratios still apply. WCAG AA requires 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+ regular). Check every text/background combination in your dark palette using a contrast checker. Dark mode failures are often insufficient contrast — grey text on dark grey background failing the minimum.
Link differentiation. In dark mode, links are often shown in a lighter blue. Make sure links are distinguishable from surrounding text not just by colour but by some other visual cue (underline, bold) for users who cannot distinguish colours.
Focus indicators. The default browser focus ring may be invisible on dark backgrounds. Test keyboard navigation in dark mode explicitly and add a custom focus style if needed:
[data-theme="dark"] :focus-visible {
outline: 2px solid var(--link-color);
outline-offset: 3px;
}
Do not use colour alone to convey information like error states or status indicators. This applies to both modes but is often overlooked when building dark themes separately from the light theme.
Running both modes through an accessibility checker (axe DevTools, WAVE) catches the majority of issues quickly.
Storing dark mode preference across subdomains
If your site spans multiple subdomains (www.jekyllhub.com, docs.jekyllhub.com, blog.jekyllhub.com), localStorage is scoped per origin — the preference set on www. does not carry over to docs.. If consistent dark mode across subdomains matters for your site, you have two options:
Cookies with the domain set to .jekyllhub.com (note the leading dot) are shared across subdomains. Switch to document.cookie for storage with SameSite=Strict; Max-Age=31536000; Path=/; Domain=.jekyllhub.com.
A shared script tag from a common domain that reads and writes a cross-subdomain cookie, then posts a message to the host page via postMessage. This is more complex but works for sophisticated multi-property setups.
For most Jekyll sites, localStorage per domain is perfectly adequate.
Dark mode is one of those features that feels small until you use a site that does not have it at night — then it feels essential. Once implemented, it requires almost no ongoing maintenance. The CSS variables approach means future colour changes just require updating a handful of variable values, and both modes update automatically. For readers who spend long hours with your content, it is one of the highest-value improvements you can make to any Jekyll site.