Reskinning Ghost's Source Theme with Nothing but Code Injection

How my blog got its terminal/neon look on the stock Ghost Source theme: site-wide code injection, theme settings, and the 62.5% rem trap behind tiny text.

Share
Reskinning Ghost's Source Theme with Nothing but Code Injection

The look of this blog (dark terminal, green prompt, neon cards) is not a custom theme. It's Ghost's default theme Source, untouched, plus one block of CSS and a bit of JavaScript in the site-wide Code Injection → Site header. About 21 KB of it.

I went this way on purpose. A forked theme means merging upstream changes forever. Code injection survives theme updates, needs no upload, and if I break something, I paste the previous version back. The trade-off is that you're styling someone else's markup, and Source has a couple of assumptions that will bite you. One of them made the whole site unreadable on my wide monitor.

TL;DR: Set Source's theme settings first (logo left, grid feed, header off, tags as primary navigation), then put all styling in code injection. Source sets html { font-size: 62.5% }, so 1rem = 10px, not 16px. Write every rem value for 10px. And Source parks the feed in grid column 3 / span 12, leaving room for a sidebar; use grid-column: 1 / -1 to get the full width.

Theme settings do half the work

Before writing any CSS, I changed four settings in Settings → Design → Theme:

Setting Before After
Navigation layout Logo in the middle Logo on the left
Post feed style List Grid
Header style Landing Off
Primary navigation Home one entry per tag

"Header off" kills Source's big landing block; I build my own hero with JS instead. The primary navigation is just tag pages (/tag/linux/, /tag/docker/ and so on), which turned out to be the most useful navigation a topic blog can have. Secondary navigation goes in the footer: About plus the legal links.

I scripted these instead of clicking, because I rebuild things more often than I'd like. Ghost stores them in its database:

// one-off, run inside the Ghost container (SQLite)
const D = require('/var/lib/ghost/current/node_modules/better-sqlite3');
const db = new D('/var/lib/ghost/content/data/ghost.db');
const set = db.prepare('update custom_theme_settings set value=? where theme=? and key=?');
set.run('Logo on the left', 'source', 'navigation_layout');
set.run('Grid', 'source', 'post_feed_style');
set.run('Off', 'source', 'header_style');
db.close();

If you're on MySQL, same tables, different client. Clicking in the admin UI is perfectly fine too.

The deploy loop

The CSS lives in a local file, injection-head.html, under version control. A small Node script copies it to the server, writes it into the codeinjection_head row of the settings table, and restarts Ghost:

const css = fs.readFileSync('/tmp/ghost-injection.html', 'utf8');
db.prepare('update settings set value=?, updated_at=? where `key`=?')
  .run(css, now, 'codeinjection_head');
docker restart ghost

The restart is not optional: Ghost keeps settings in memory, so a direct database write is invisible until the process reloads. If you use the Admin API or the UI instead, no restart needed, and that's the safer route for most people. I write to the DB because my pipeline already does, and before bigger changes I save the live version as a backup file next to it.

The skin, condensed

Colours as variables, so everything else stays readable:

:root {
  --tg-bg: #07090b;
  --tg-bg-soft: #0d1114;
  --tg-line: #1c2a2e;
  --tg-text: #d5e2df;
  --tg-dim: #71877f;
  --tg-green: #3ee08f;
  --tg-cyan: #4fd1e8;
  --tg-mono: ui-monospace, "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
  --ghost-accent-color: #4fd1e8;
}
body { background: var(--tg-bg); color: var(--tg-text); }

No web fonts, no external scripts. Every extra request costs page speed, and the system monospace stack looks fine on every OS.

Then the shell flavour, mostly through pseudo-elements so the markup stays Ghost's:

.gh-container-title::before { content: "$ "; color: var(--tg-green); }
.gh-content h2::before { content: "## "; color: var(--tg-green); font-family: var(--tg-mono); }
.gh-navigation-menu .nav-current a::before { content: "> "; color: var(--tg-green); }

.gh-card { background: var(--tg-bg-soft); border: 1px solid var(--tg-line); border-radius: 6px; }
.gh-card:hover { border-color: rgba(79, 209, 232, .7); box-shadow: 0 12px 30px -14px rgba(79, 209, 232, .6); }

The footer's "Powered by Ghost" goes away with CSS, and the copyright gets my own text. The legal links come from secondary navigation, so they're real links Ghost renders, not something injected:

.gh-footer-copyright { font-size: 0 !important; }
.gh-footer-copyright a { display: none; }
.gh-footer-copyright::before { content: "© 2026 Lukas"; font-size: 1.28rem; color: var(--tg-dim); }

Yes, the year is hard-coded. January-me will have to remember that.

The JavaScript part

A DOMContentLoaded handler in the same injection does what CSS can't:

  • Prompt-style logo: replaces the site title with a user@host:~/blog$ prompt.
  • Home hero: builds a small "$ cat README.md" header above the feed, with the tag links pulled from the navigation and the date of the latest post.
  • Code bars: wraps every <pre> in a bar showing the language (from Ghost's language-xyz class) and a copy button.
  • TL;DR and gotcha boxes: a paragraph starting with bold "TL;DR" gets a class; an H2 starting with "Gotcha" plus everything up to the next H2 gets wrapped into a box. That's why my posts are written with exactly those headings.
  • Table of contents: built from every h2[id], fixed on the side on wide screens, collapsible inline on narrow ones, with the active section highlighted via IntersectionObserver.

The code-bar piece, shortened:

content.querySelectorAll('pre').forEach(function (pre) {
  var code = pre.querySelector('code');
  var lang = code && (code.className.match(/language-([\w+-]+)/) || [])[1];
  var wrap = document.createElement('div');
  wrap.className = 'tg-code';
  var bar = document.createElement('div');
  bar.className = 'tg-code-bar';
  bar.innerHTML = '<span>' + (lang || 'text') + '</span><button type="button">copy</button>';
  pre.parentNode.insertBefore(wrap, pre);
  wrap.appendChild(bar);
  wrap.appendChild(pre);
  bar.querySelector('button').addEventListener('click', function () {
    navigator.clipboard.writeText((code || pre).innerText);
  });
});

The real version has a fallback that selects the text when the clipboard API isn't available. Keep these scripts dumb: they run on every page view, and anything that throws in there takes the rest of the handler with it.

Gotcha: Source's root font size is 62.5%

Source sets this:

html { font-size: 62.5%; }

That makes 1rem = 10px instead of the browser default 16px. It's an old trick to make rem maths easy. But I'd written my whole first version with the 16px habit in my head: font-size: .85rem for nav links, .74rem for card meta, 1rem for the logo. With Source, those came out at roughly 7 to 9 pixels. Navigation, code blocks and the TOC were basically unreadable, and on a wide screen it looked like a broken page.

The fix was mechanical: multiply every rem value in the injection by 1.6.

/* before (written for 16px rem) */
.gh-navigation-menu a { font-size: .85rem; }
/* after (Source: 1rem = 10px) */
.gh-navigation-menu a { font-size: 1.36rem; }

And a comment at the top of the width section, so future me doesn't do it again: Source root = 10px, so 1rem = 10px. If you inject CSS into any Ghost theme, check getComputedStyle(document.documentElement).fontSize in the console first. It takes two seconds.

Gotcha: the feed lives in a narrow grid column

Source's container is a CSS grid, and the feed and the section title sit in grid-column: 3 / span 12, leaving outer columns free for a sidebar the theme can show. With "Header: Off" and no sidebar, my grid looked boxed in: a narrow strip of cards in the middle of a big empty page.

.gh-container.is-grid .gh-main, .gh-container.is-list .gh-main,
.gh-container.is-grid .gh-container-title, .gh-container.is-list .gh-container-title {
  grid-column: 1 / -1;
}

Use the browser's grid inspector on .gh-container and you'll see it immediately.

Wide screens

On a big monitor the default content width makes posts feel like a phone screen. From 1700px up I widen the container, give the text a bit more room and size, and switch the home grid to four columns:

@media (min-width: 1700px) {
  :root { --container-width: 1560px; --content-width: 800px; }
  .gh-content { font-size: 1.9rem; }   /* 19px, because 1rem = 10px */
  .home-template .gh-container.is-grid .gh-feed { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 2300px) {
  :root { --container-width: 1800px; --content-width: 860px; }
  html { font-size: 68.75%; }
}

Source exposes --container-width and --content-width as variables, which is the nicest thing about it: you change two numbers instead of chasing max-widths across a dozen selectors. The newest post spans the full row as a featured tile on every breakpoint.

Was it worth not forking?

For me, yes. Theme updates don't touch the skin, because the skin isn't in the theme. The whole design is one file I can diff and re-apply with one script run, which matters more than you'd think once you've had to rebuild a blog from scratch (that story is here). The cost is that I'm coupled to Source's class names; if a future version renames .gh-card, I'll find out the visual way.

If you're running Ghost yourself: the proxy part is in Apache as a reverse proxy for Docker apps and the certificate side in moving off a dead panel's Let's Encrypt to certbot. And whatever you inject, check the root font size first.


Related posts