Lesson 5 · Core course
Design for reading and follow system dark mode
a simple reading layout with System, Light, and Dark choices that work across pages.
Sign in to save progress across devices
On this page
Start with comfortable line lengths, clear headings, visible links, and generous spacing. These serve personal essays as well as technical posts. A theme is useful when it supports your writing; you can also own a small stylesheet like the one below. Astro’s styling guide describes global and component styles.
1. Replace the global stylesheet
Create light and dark color variables. CSS follows the operating system even when JavaScript is disabled. Explicit saved preferences will override that default.
File: src/styles/global.css
:root {
color-scheme: light;
--paper: #f7f5ef;
--ink: #282a26;
--muted: #596157;
--accent: #38634d;
--line: #d3d9cf;
--tint: #e9ede5;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--paper: #22231f;
--ink: #e9e8df;
--muted: #b3baab;
--accent: #b0d8b8;
--line: #485043;
--tint: #30362c;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--paper: #22231f;
--ink: #e9e8df;
--muted: #b3baab;
--accent: #b0d8b8;
--line: #485043;
--tint: #30362c;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--paper); color: var(--ink); font: 1.1rem/1.75 system-ui, sans-serif; overflow-wrap: break-word; }
.shell { width: min(100% - 2rem, 46rem); margin-inline: auto; }
.site-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 1.5rem; border-bottom: 1px solid var(--line); }
.site-name { font-weight: 700; text-decoration: none; }
nav { display: flex; flex-wrap: wrap; gap: 1rem; }
main { padding-block: 2.5rem; min-height: 60vh; }
h1, h2, h3 { line-height: 1.2; text-wrap: balance; }
h1 { font-size: clamp(2rem, 6vw, 3rem); letter-spacing: -.035em; }
h2 { font-size: 1.55rem; }
a { color: var(--accent); text-underline-offset: .2em; }
a:hover { text-decoration-thickness: 2px; }
.muted, .site-footer { color: var(--muted); }
.site-footer { padding-block: 2rem; border-top: 1px solid var(--line); }
.post-list { padding: 0; list-style: none; }
.post-list li { padding-block: 1.25rem; border-bottom: 1px solid var(--line); }
.post-list h2 { margin-block: 0 .5rem; }
.post-list p { margin-block: .5rem 0; }
.prose > * + * { margin-top: 1.3rem; }
.prose h2, .prose h3 { margin-top: 2rem; }
.prose img { display: block; max-width: 100%; height: auto; border-radius: .5rem; }
.prose blockquote { margin-inline: 0; padding-left: 1.25rem; border-left: 3px solid var(--accent); color: var(--muted); }
.prose pre { max-width: 100%; padding: 1rem; overflow-x: auto; border: 1px solid var(--line); background: var(--tint); border-radius: .4rem; }
.prose :not(pre) > code { padding: .1em .3em; background: var(--tint); border-radius: .2rem; }
.prose table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; }
.prose th, .prose td { padding: .5rem .75rem; border: 1px solid var(--line); }
.draft-notice { padding: .75rem; border: 1px solid var(--line); background: var(--tint); }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
.skip-link { position: absolute; left: 1rem; top: -5rem; padding: .5rem; background: var(--paper); z-index: 10; }
.skip-link:focus { top: 1rem; }
.theme-control { display: none; align-items: center; gap: .5rem; font-size: .9rem; }
:root[data-theme] .theme-control { display: flex; }
select { min-height: 2.75rem; padding: .3rem .5rem; background: var(--paper); color: var(--ink); border: 1px solid var(--line); border-radius: .3rem; font: inherit; }
@media print {
:root { color-scheme: light; --paper: white; --ink: black; --muted: #333; --accent: black; --line: #aaa; --tint: #eee; }
.site-header, .site-footer, .skip-link { display: none; }
main { padding: 0; }
}
Print variables need to override the more specific dark selectors too. Create src/styles/print.css with this final override:
File: src/styles/print.css
@media print {
:root, :root[data-theme="dark"], :root:not([data-theme="light"]) {
color-scheme: light;
--paper: white;
--ink: black;
--muted: #333;
--accent: black;
--line: #aaa;
--tint: #eee;
}
}
The revised layout below imports it after the main stylesheet. This keeps the final override explicit.
2. Create the appearance control
File: src/components/ThemePicker.astro
<label class="theme-control">
<span>Appearance</span>
<select data-theme-select aria-label="Appearance">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
A labeled native select is keyboard accessible and keeps the first version simple. It stays hidden if JavaScript cannot initialize it.
3. Apply the preference before the page paints
File: src/scripts/theme.js
(() => {
const key = 'my-blog-theme';
const root = document.documentElement;
const system = window.matchMedia('(prefers-color-scheme: dark)');
const normalize = (value) => value === 'light' || value === 'dark' ? value : 'system';
let preference = 'system';
try { preference = normalize(localStorage.getItem(key)); } catch {}
const apply = () => {
root.dataset.theme = preference === 'system'
? (system.matches ? 'dark' : 'light') : preference;
document.querySelectorAll('[data-theme-select]').forEach((select) => {
if (select instanceof HTMLSelectElement) select.value = preference;
});
};
apply();
document.addEventListener('DOMContentLoaded', apply, { once: true });
document.addEventListener('change', (event) => {
const select = event.target;
if (!(select instanceof HTMLSelectElement) || !select.matches('[data-theme-select]')) return;
preference = normalize(select.value);
try {
if (preference === 'system') localStorage.removeItem(key);
else localStorage.setItem(key, preference);
} catch {}
apply();
});
system.addEventListener('change', () => {
if (preference === 'system') apply();
});
window.addEventListener('storage', (event) => {
if (event.key !== key && event.key !== null) return;
preference = event.key === null ? 'system' : normalize(event.newValue);
apply();
});
})();
Blocked browser storage should not break the page. The selection still works for the current page when persistence is unavailable. Selecting System removes the override, so future operating-system changes apply automatically.
4. Replace the shared layout
The raw script is trusted project source. Inline it in the head so a saved preference takes effect early. Do not feed untrusted reader content into set:html.
File: src/layouts/Page.astro
---
import '../styles/global.css';
import '../styles/print.css';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
import ThemePicker from '../components/ThemePicker.astro';
import themeScript from '../scripts/theme.js?raw';
interface Props { title?: string; description?: string; noindex?: boolean; }
const { title = SITE_TITLE, description = SITE_DESCRIPTION, noindex = false } = Astro.props;
const pageTitle = title === SITE_TITLE ? title : `${title} · ${SITE_TITLE}`;
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<script is:inline set:html={themeScript}></script>
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="shell site-header">
<a class="site-name" href="/">{SITE_TITLE}</a>
<nav aria-label="Main navigation">
<a href="/">Home</a><a href="/blog/">Writing</a><a href="/about/">About</a>
</nav>
<ThemePicker />
</header>
<main id="main" class="shell" tabindex="-1"><slot /></main>
<footer class="shell site-footer">Written with curiosity.</footer>
</body>
</html>
Checkpoint and exercise
Test System, Light, and Dark; reload and navigate to another page. With System selected, change your operating-system appearance while the page is open. Open two tabs and check that changing one updates the other.
Disable JavaScript: the page should still follow the system appearance. Tab through links and the control. At a narrow phone width, text and navigation should fit; a long code line should scroll inside its block. Print preview should use light colors even when Dark is selected.
Change the accent color and one heading size. Read a full article in both modes before deciding whether the change helps.