Adds step-by-step theme bootstrap checklist (E1-A), consolidated Tailwind v4 deployment checklist (E1-B), Hugo version column in reference table (E1-C), and flash-prevention cross-reference (E1-D). Validation: held-out task built a complete Tailwind v4 theme with dark mode and responsive images — 9 pages, 2 images at 3 sizes each, 0 errors, 0 warnings. All 4 edits accepted.
41 KiB
Design, UX & Accessibility for CMS Themes
Platform-agnostic guidance for building beautiful, inclusive, and performant CMS themes. Applies to Hugo, WordPress, Ghost, Statamic, Craft CMS, Jekyll, Eleventy — any system that renders templates to HTML.
Table of Contents
- Typography Systems
- Accessible Color & Contrast
- Spacing & Layout
- Design Tokens & Theming
- Semantic HTML & Landmarks
- ARIA & Dynamic Content
- Keyboard Navigation & Focus
- Accessible Forms & Search
- Content-First Design Patterns
- Navigation & Information Architecture
- Engagement Patterns
- Performance & Core Web Vitals
- Modern CSS for Themes
- Theme Testing & QA
- Sources & References
1. Typography Systems
1.1 Fluid Type Scale
A modular type scale ensures visual harmony across headings and body text. Use clamp() to size fluidly between viewport widths without media queries:
:root {
--step--2: clamp(0.6944rem, 0.6515rem + 0.2144vw, 0.8333rem);
--step--1: clamp(0.8333rem, 0.7708rem + 0.3125vw, 1rem);
--step-0: clamp(1rem, 0.9115rem + 0.4427vw, 1.25rem);
--step-1: clamp(1.2rem, 1.0755rem + 0.6224vw, 1.5625rem);
--step-2: clamp(1.44rem, 1.2665rem + 0.8671vw, 1.9531rem);
--step-3: clamp(1.728rem, 1.4885rem + 1.1979vw, 2.4414rem);
--step-4: clamp(2.074rem, 1.7466rem + 1.6372vw, 3.0518rem);
--step-5: clamp(2.488rem, 2.0463rem + 2.2084vw, 3.8147rem);
}
h1 { font-size: var(--step-5); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-2); }
body { font-size: var(--step-0); }
small { font-size: var(--step--1); }
Typography best practices:
- Body text: 16–18px (1rem–1.125rem) as base
- Line height: 1.5–1.7 for body, 1.1–1.3 for headings
- Measure (line length): 45–75 characters per line, ideal 66 CPL. WCAG 1.4.8 (AAA) mandates max 80 CPL.
- Use
chunits for text container width:max-width: 65ch - Limit to 2–3 font families and 3–4 weights total
1.2 Font Loading Strategy
Self-host fonts as WOFF2 for performance and privacy:
@font-face {
font-family: 'BodyFont';
src: url('/fonts/body-regular.woff2') format('woff2');
font-display: swap; /* Show fallback text immediately */
font-weight: 400;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
<!-- Preload critical fonts -->
<link rel="preload" href="/fonts/body-regular.woff2" as="font" type="font/woff2" crossorigin>
Never use font-display: block — it hides text for up to 3 seconds while the font loads, creating a flash of invisible text (FOIT). Use swap to render fallback text immediately, or optional if you'd rather fall back to the system font entirely on slow connections.
1.3 WCAG Text Spacing (SC 1.4.12)
Users may override your text spacing for readability. Ensure no content loss when these overrides are applied:
.prose p {
line-height: 1.5; /* minimum 1.5× font size */
margin-bottom: 1.5em; /* 1.5× spacing between paragraphs */
word-spacing: 0.16em;
letter-spacing: 0.12em;
}
The WCAG text spacing bookmarklet applies these overrides — test with it during development.
2. Accessible Color & Contrast
2.1 WCAG Contrast Requirements
WCAG 2.2 minimum ratios (SC 1.4.3 & 1.4.6):
| Level | Normal text | Large text (18pt+ / 14pt bold) | UI components & graphics |
|---|---|---|---|
| AA | ≥ 4.5:1 | ≥ 3:1 | ≥ 3:1 (SC 1.4.11) |
| AAA | ≥ 7:1 | ≥ 4.5:1 | n/a |
2.2 Designing an Accessible Palette
Choose color tokens that meet contrast from the start — don't fix them later:
:root {
/* Text — all ≥8.6:1 on white */
--color-text-primary: #1a1a1a; /* 15:1 on white */
--color-text-secondary: #4a4a4a; /* 8.6:1 on white */
/* Use muted text sparingly — it must still be readable */
--color-text-muted: #6b6b6b; /* 5.2:1 on white — small text minimum */
/* Brand colors with accessible contrast on their expected backgrounds */
--color-primary: #0055cc; /* 4.8:1 on white, passes AA for text */
--color-primary-text: #ffffff; /* for buttons on --color-primary */
/* Surface */
--color-surface: #ffffff;
--color-surface-secondary: #f5f5f5; /* Sufficient contrast from white for borders */
--color-border: #d4d4d4;
}
Hard rules:
- Never convey information by color alone — add icons, underlines, or text labels
- Links must have ≥ 3:1 contrast from body text AND an underline OR hover/focus underline
- Test all color pairs with WebAIM contrast checker or axe DevTools before shipping
- Test with
prefers-contrast: more— a user preference for increased contrast
2.3 Dark Mode
@media (prefers-color-scheme: dark) {
:root {
--color-surface: #1a1a2e;
--color-text-primary: #e8e8e8;
--color-text-secondary: #a0a0a0;
--color-border: #2a2a3e;
--color-link: #6ba3ff;
--color-link-hover: #8bb9ff;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
}
}
/* Manual toggle override */
[data-theme="dark"] {
--color-surface: #1a1a2e;
--color-text-primary: #e8e8e8;
/* ... same overrides ... */
}
Modern browsers support light-dark() for simpler theme switching (Chrome 123+, Firefox 128+):
:root {
color-scheme: light dark;
--color-surface: light-dark(#ffffff, #1a1a2e);
--color-text-primary: light-dark(#1a1a1a, #e8e8e8);
--color-link: light-dark(#0055cc, #6ba3ff);
}
2.4 Theme Switching Without Flash
Apply the user's preferred theme before any CSS renders to prevent a flash of incorrect theme:
<script>
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (theme === null && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
Place this inline in <head> before any stylesheets. It blocks rendering for microseconds but prevents the jarring light-to-dark flash.
3. Spacing & Layout
3.1 Consistent Spacing Scale
Base on a 4px or 8px unit:
:root {
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.5rem; /* 24px */
--space-6: 2rem; /* 32px */
--space-7: 3rem; /* 48px */
--space-8: 4rem; /* 64px */
--space-9: 6rem; /* 96px */
}
Vertical rhythm — consistent spacing between elements without thinking about each one:
/* CUBE CSS flow utility */
.flow > * + * {
margin-top: var(--flow-space, 1em);
}
3.2 Content-Out Page Layout
A content-first grid that gives you full-bleed and constrained regions without nested wrappers:
.page-layout {
display: grid;
grid-template-columns:
[full-start] minmax(1rem, 1fr)
[main-start] minmax(0, 65ch)
[main-end] minmax(1rem, 1fr)
[full-end];
}
.page-layout > * {
grid-column: main-start / main-end; /* All children default to content column */
}
.page-layout > .full-width {
grid-column: full-start / full-end; /* Opt in to full bleed */
}
3.3 Responsive Card Grid
No media queries needed:
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
gap: var(--space-5);
}
Responsive testing guidance:
- Use content-driven breakpoints, not device-driven ones
- Test at every 100px width from 320px to 1600px
- WCAG 1.4.10 (Reflow) requires no horizontal scroll at 320px equivalent width
- Consider container queries for component-level responsiveness
4. Design Tokens & Theming
4.1 Token Architecture
Layer tokens in a hierarchy: Global → Semantic → Component
/* Layer 1: Raw values (seldom change) */
:root {
--color-blue-600: #0055cc;
--color-blue-700: #003d99;
--font-body: 'Inter', system-ui, sans-serif;
--font-heading: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
}
/* Layer 2: Semantic tokens (theme-aware) */
:root {
--color-surface: #ffffff;
--color-text: #1a1a1a;
--color-link: var(--color-blue-600);
--color-link-hover: var(--color-blue-700);
--spacing-section: 4rem;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.1);
}
/* Layer 3: Component-level overrides (in component CSS files) */
.card {
--card-padding: var(--space-4);
--card-radius: var(--border-radius-md);
}
4.2 User Preference Detection
Always respect these user preferences:
/* High contrast */
@media (prefers-contrast: more) {
:root {
--color-text: #000000;
--color-text-secondary: #1a1a1a;
--color-border: #000000;
}
}
/* Reduced transparency */
@media (prefers-reduced-transparency: reduce) {
* {
opacity: 1 !important;
backdrop-filter: none !important;
}
}
5. Semantic HTML & Landmarks
5.1 Page Landmarks (WCAG 1.3.1)
Every CMS theme should provide these landmark regions:
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header role="banner">
<nav aria-label="Main navigation">
<!-- site nav -->
</nav>
</header>
<main id="main-content">
<article>
<h1>Page Title</h1>
<!-- content -->
</article>
<aside aria-label="Related content">
<!-- sidebar -->
</aside>
</main>
<footer role="contentinfo">
<!-- footer content -->
</footer>
</body>
5.2 Heading Hierarchy (WCAG 1.3.1)
- One
<h1>per page (usually the page/post title in CMS) - Heading levels must not skip (h1 → h2 → h3, never h1 → h3)
- For CMS themes: ensure editors can't break hierarchy — provide visual guidance in the editor, or use a render hook that maps heading levels to a semantic hierarchy
<article>
<h1>Post Title</h1>
<section aria-labelledby="section1-heading">
<h2 id="section1-heading">Introduction</h2>
<h3>Sub-point</h3>
</section>
</article>
5.3 Proper <nav> Usage
- Use
<nav>only for primary and secondary navigation blocks, not all link groups - Use
aria-labelto disambiguate multiple navs:<nav aria-label="Breadcrumb">,<nav aria-label="Main"> - Footer links should be wrapped in
<nav aria-label="Footer">only if they constitute navigation
5.4 Image Alt Text
Every <img> must have appropriate alt:
- Informative images: describe what's visually shown
- Decorative images:
alt=""(empty) — never omit the attribute - Linked images: describe the link destination, not the image
- Complex images (charts, diagrams):
altfor summary, plus a longer description nearby or viaaria-describedby
6. ARIA & Dynamic Content
6.1 Golden Rule
Use semantic HTML first. ARIA only when HTML semantics are insufficient.
6.2 Common ARIA Patterns for CMS Themes
<!-- Skip link -->
<a class="skip-link" href="#main-content">Skip to main content</a>
<!-- Breadcrumb nav -->
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/blog">Blog</a></li>
<li aria-current="page">Current Post</li>
</ol>
</nav>
<!-- Mobile menu toggle -->
<button
aria-expanded="false"
aria-controls="main-nav-menu"
aria-label="Open navigation menu"
type="button"
class="nav-toggle">
<span class="hamburger-icon"></span>
</button>
6.3 Screen Reader Announcements (Live Regions)
<!-- Status after form submission -->
<div role="status" aria-live="polite" class="form-status">
<!-- Injected: "Thank you! Your comment is awaiting moderation." -->
</div>
<!-- Search results updates -->
<div aria-live="polite" aria-atomic="true" class="search-results-count">
<!-- "Showing 12 results" injected on filter change -->
</div>
aria-live values:
polite— announce when user is idle (default for status messages)assertive— announce immediately (use sparingly, for critical errors)role="status"— implicitaria-live="polite", prefer this for status messages
Best practices:
- Live regions must exist in the DOM before content changes
- Use
aria-atomic="true"when replacing entire content so the whole region is read - Empty the region, then re-add content to trigger re-announcement
7. Keyboard Navigation & Focus
7.1 Visible Focus Indicators (WCAG 2.4.7)
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 2px;
}
/* Never do: :focus { outline: none; } without providing a replacement */
/* Never use: outline: none on :focus without also adding :focus-visible styles */
7.2 Skip Link Pattern
.skip-link {
position: absolute;
top: -100%;
left: 0;
z-index: 10000;
padding: 0.5rem 1rem;
background: var(--color-primary);
color: white;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
7.3 Dropdown Keyboard Support
All navigation must work by keyboard (WCAG 2.1.1):
const menuButton = document.querySelector('[aria-haspopup="true"]');
const menu = document.getElementById(menuButton.getAttribute('aria-controls'));
menuButton.addEventListener('click', () => {
const expanded = menuButton.getAttribute('aria-expanded') === 'true' ? false : true;
menuButton.setAttribute('aria-expanded', expanded);
});
menuButton.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
menu.querySelector('a, button')?.focus();
}
});
// Within menu: Arrow keys navigate items, Escape closes
menu.addEventListener('keydown', (e) => {
const items = [...menu.querySelectorAll('a, button')];
const currentIndex = items.indexOf(document.activeElement);
switch (e.key) {
case 'ArrowDown': e.preventDefault(); items[(currentIndex + 1) % items.length]?.focus(); break;
case 'ArrowUp': e.preventDefault(); items[(currentIndex - 1 + items.length) % items.length]?.focus(); break;
case 'Escape': e.preventDefault(); menuButton.focus(); menuButton.setAttribute('aria-expanded', 'false'); break;
case 'Home': e.preventDefault(); items[0]?.focus(); break;
case 'End': e.preventDefault(); items[items.length - 1]?.focus(); break;
}
});
7.4 Reduced Motion (WCAG 2.3.3)
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
This is the most commonly used pattern. Apply it globally in every theme that uses any animations.
7.5 Font Scaling (WCAG 1.4.4)
Users must be able to zoom text to 200% without loss of content or functionality:
html {
font-size: 100%; /* Respect user's default browser font size */
}
body {
font-size: 1rem; /* Scales with user preferences */
}
/* Never use px for font sizes in components */
8. Accessible Forms & Search
8.1 Search Form
<form role="search" action="/search" method="get">
<div class="search-field">
<label for="search-input" class="sr-only">Search</label>
<input
type="search"
id="search-input"
name="q"
placeholder="Search articles..."
aria-describedby="search-hint"
/>
<span id="search-hint" hidden>Use Enter to search</span>
</div>
<button type="submit">Search</button>
</form>
8.2 Screen-Reader-Only Utility
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
Use .sr-only for labels that are visually obvious but need screen reader context (e.g., a search button with an icon but no text).
8.3 Comment Form with Inline Validation
<form method="post" action="/comments" novalidate>
<div class="field">
<label for="comment-name">Name <span aria-hidden="true">*</span></label>
<input type="text" id="comment-name" name="name" required aria-required="true" />
<span class="field-error" id="comment-name-error" role="alert" hidden>
Name is required
</span>
</div>
<div class="field">
<label for="comment-text">Comment <span aria-hidden="true">*</span></label>
<textarea id="comment-text" name="text" required aria-required="true" rows="5"></textarea>
</div>
<button type="submit">Post Comment</button>
</form>
9. Content-First Design Patterns
9.1 Prose Container
.prose {
max-width: 65ch; /* 65 characters per line — ideal readability */
margin-inline: auto;
font-size: var(--step-0);
line-height: 1.7;
}
/* Heading rhythm */
.prose h1 { font-size: var(--step-5); margin-top: 2.5em; margin-bottom: 0.5em; }
.prose h2 { font-size: var(--step-3); margin-top: 2em; margin-bottom: 0.5em; }
.prose h3 { font-size: var(--step-2); margin-top: 1.5em; margin-bottom: 0.5em; }
.prose h4 { font-size: var(--step-1); margin-top: 1.25em; margin-bottom: 0.5em; }
.prose p { margin-bottom: 1.5em; }
.prose li { margin-bottom: 0.5em; }
/* Tighter spacing after headings */
.prose h2 + p, .prose h3 + p { margin-top: 0; }
9.2 Responsive Images
Always provide explicit dimensions plus responsive variants:
<picture>
<source
media="(max-width: 599px)"
srcset="article-portrait-sm.jpg 400w, article-portrait-lg.jpg 600w"
sizes="100vw"
/>
<source
media="(min-width: 600px)"
srcset="article-landscape-sm.jpg 600w, article-landscape-md.jpg 900w, article-landscape-lg.jpg 1200w"
sizes="(min-width: 900px) 65ch, 90vw"
/>
<img
src="article-landscape-md.jpg"
alt="Descriptive alt text"
width="900"
height="506"
loading="lazy"
decoding="async"
/>
</picture>
Aspect ratio boxes — the modern way:
.featured-image {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
object-fit: cover;
}
9.3 Figures
<figure>
<picture>
<img src="photo.jpg" alt="Mountain landscape at sunset" width="900" height="600" />
</picture>
<figcaption>Sunset over the Rocky Mountains, Colorado.</figcaption>
</figure>
figure {
margin: var(--space-6) 0;
}
figure img {
width: 100%;
height: auto;
border-radius: var(--border-radius-md);
}
figcaption {
margin-top: var(--space-2);
font-size: var(--step--1);
color: var(--color-text-secondary);
text-align: center;
}
9.4 Blockquotes
blockquote {
margin: var(--space-6) 0;
padding: var(--space-4) var(--space-5);
border-inline-start: 4px solid var(--color-accent);
font-style: italic;
font-size: var(--step-1);
color: var(--color-text-secondary);
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
blockquote cite {
display: block;
margin-top: var(--space-2);
font-size: var(--step--1);
font-style: normal;
color: var(--color-text-muted);
}
blockquote cite::before {
content: '\2014\00A0'; /* em dash + space */
}
9.5 Code Blocks
/* Inline code */
code {
font-family: var(--font-mono);
font-size: 0.9em;
padding: 0.15em 0.3em;
background: var(--color-surface-secondary, #f0f0f0);
border-radius: var(--border-radius-sm);
word-break: break-word;
}
/* Code blocks */
pre {
font-family: var(--font-mono);
font-size: 0.9rem;
line-height: 1.6;
padding: var(--space-4);
overflow-x: auto;
border-radius: var(--border-radius-md);
background: #1a1a2e;
color: #e8e8e8;
tab-size: 2;
}
WCAG 1.4.10 exception: Code blocks and tables may horizontally scroll at 320px viewport width when the content cannot reflow.
9.6 Responsive Tables
Two approaches depending on table complexity:
Approach 1 — Overflow wrapper (for data tables):
.table-wrapper {
overflow-x: auto;
max-width: 100%;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--step--1);
}
th, td {
padding: var(--space-2) var(--space-3);
text-align: left;
border-bottom: 1px solid var(--color-border);
}
th {
font-weight: 600;
background: color-mix(in srgb, var(--color-surface) 95%, black);
}
Approach 2 — Card layout on small screens (for simple tables):
@media (max-width: 600px) {
table.responsive-card thead {
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0);
}
table.responsive-card tbody,
table.responsive-card tr,
table.responsive-card td { display: block; }
table.responsive-card tr {
padding: var(--space-3);
margin-bottom: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
}
table.responsive-card td { padding: var(--space-1) 0; border: none; }
table.responsive-card td::before {
content: attr(data-label);
display: block;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-secondary);
}
}
10. Navigation & Information Architecture
10.1 Accessible Dropdown Menu
<nav aria-label="Main navigation">
<ul class="nav-list" role="list">
<li><a href="/">Home</a></li>
<li class="nav-item-has-children">
<button
aria-haspopup="true"
aria-expanded="false"
aria-controls="sub-menu-1"
class="nav-link"
>
Products
<svg aria-hidden="true" class="chevron" width="12" height="12"><use href="#chevron"/></svg>
</button>
<ul id="sub-menu-1" class="sub-menu" role="menu" aria-label="Products">
<li role="none"><a href="/products/a" role="menuitem">Service A</a></li>
<li role="none"><a href="/products/b" role="menuitem">Service B</a></li>
</ul>
</li>
</ul>
</nav>
.sub-menu {
display: none;
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-sm);
z-index: 100;
}
.nav-item-has-children:hover .sub-menu,
.nav-item-has-children:focus-within .sub-menu {
display: block;
}
10.2 Mobile Hamburger Menu
<button
class="hamburger"
aria-controls="mobile-menu"
aria-expanded="false"
aria-label="Open menu"
type="button"
>
<span class="hamburger-box">
<span class="hamburger-inner"></span>
</span>
</button>
<nav id="mobile-menu" class="mobile-menu" aria-label="Mobile navigation" hidden>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/blog">Blog</a></li>
</ul>
</nav>
10.3 Breadcrumbs with Structured Data
<nav aria-label="Breadcrumb">
<ol itemscope itemtype="https://schema.org/BreadcrumbList">
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="/"><span itemprop="name">Home</span></a>
<meta itemprop="position" content="1">
</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
<a itemprop="item" href="/blog"><span itemprop="name">Blog</span></a>
<meta itemprop="position" content="2">
</li>
<li itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem" aria-current="page">
<span itemprop="name">Current Post</span>
<meta itemprop="position" content="3">
</li>
</ol>
</nav>
.breadcrumb li:not(:last-child)::after {
content: '/';
margin-left: var(--space-1);
color: var(--color-text-muted);
}
10.4 Table of Contents
<nav aria-label="Table of contents" class="toc">
<h2 class="toc-title">On this page</h2>
<ol class="toc-list">
<li><a href="#section-1">Introduction</a></li>
<li><a href="#section-2">Getting Started</a>
<ol>
<li><a href="#section-2-1">Prerequisites</a></li>
</ol>
</li>
</ol>
</nav>
.toc {
position: sticky;
top: var(--space-5);
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.toc-list a {
color: var(--color-text-secondary);
text-decoration: none;
padding: var(--space-1) 0;
display: block;
border-left: 2px solid transparent;
padding-left: var(--space-2);
}
.toc-list a:hover,
.toc-list a:focus-visible {
color: var(--color-link);
border-left-color: var(--color-link);
}
10.5 Pagination
<nav aria-label="Pagination">
<ul class="pagination">
<li><a href="/blog/page/2" aria-label="Previous page">« Prev</a></li>
<li><a href="/blog" aria-label="Page 1">1</a></li>
<li><a href="/blog/page/2" aria-label="Page 2" aria-current="page">2</a></li>
<li><a href="/blog/page/3" aria-label="Page 3">3</a></li>
<li><span class="pagination-ellipsis">…</span></li>
<li><a href="/blog/page/10" aria-label="Page 10">10</a></li>
<li><a href="/blog/page/3" aria-label="Next page">Next »</a></li>
</ul>
</nav>
10.6 Search UI with Live Region
<form role="search" action="/search" method="get" class="search-form">
<label for="nav-search" class="sr-only">Search articles</label>
<input type="search" id="nav-search" name="q" placeholder="Search..."
aria-describedby="search-instructions" autocomplete="off" />
<span id="search-instructions" class="sr-only">Type your query and press Enter.</span>
<button type="submit" aria-label="Submit search">
<svg aria-hidden="true" width="16" height="16"><use href="#search-icon"/></svg>
</button>
</form>
<!-- Results live region -->
<div role="status" aria-live="polite" aria-atomic="true" class="search-status" hidden></div>
<div id="search-results" aria-label="Search results"></div>
11. Engagement Patterns
11.1 Reading Progress Indicator
<div class="reading-progress" role="progressbar" aria-label="Reading progress"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
.reading-progress {
position: fixed;
top: 0; left: 0;
height: 3px;
background: var(--color-accent);
width: 0%;
z-index: 1000;
transition: width 100ms linear;
}
window.addEventListener('scroll', () => {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
const bar = document.querySelector('.reading-progress');
if (bar) {
bar.style.width = progress + '%';
bar.setAttribute('aria-valuenow', Math.round(progress));
}
}, { passive: true });
11.2 Dark Mode Toggle
<button id="theme-toggle" aria-label="Switch to dark mode" aria-pressed="false" type="button">
<svg aria-hidden="true" class="sun-icon" width="20" height="20"><use href="#sun"/></svg>
<svg aria-hidden="true" class="moon-icon" width="20" height="20" hidden><use href="#moon"/></svg>
</button>
const toggle = document.getElementById('theme-toggle');
const html = document.documentElement;
toggle.addEventListener('click', () => {
const currentTheme = html.getAttribute('data-theme') || 'light';
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
toggle.setAttribute('aria-label', `Switch to ${currentTheme} mode`);
toggle.setAttribute('aria-pressed', newTheme === 'dark');
document.querySelector('.sun-icon').hidden = newTheme === 'dark';
document.querySelector('.moon-icon').hidden = newTheme === 'light';
});
Important: The flash-prevention script (section 2.4) MUST be placed inline in
<head>BEFORE any stylesheets. Without it, users see a flash of the wrong theme on page load. The toggle button JS above handles the interaction; the flash-prevention script prevents the jarring light-to-dark transition.
11.3 Social Sharing (Lightweight)
Use the native Web Share API with clipboard fallback — no third-party scripts:
<aside aria-label="Share this article">
<button type="button" class="share-button" data-share-url="https://example.com/post">
<svg aria-hidden="true" width="16" height="16"><use href="#share-icon"/></svg>
Share
</button>
</aside>
document.querySelector('.share-button')?.addEventListener('click', async (e) => {
const url = e.currentTarget.dataset.shareUrl || window.location.href;
if (navigator.share) {
try {
await navigator.share({ title: document.title, url });
} catch (err) {
if (err.name !== 'AbortError') console.error(err);
}
} else {
try {
await navigator.clipboard.writeText(url);
// Announce to screen reader
const status = document.getElementById('share-status');
if (status) status.textContent = 'Link copied to clipboard';
} catch {
window.location.href = `mailto:?body=${encodeURIComponent(url)}&subject=${encodeURIComponent(document.title)}`;
}
}
});
11.4 Newsletter Signup
<section aria-labelledby="newsletter-heading" class="newsletter">
<h2 id="newsletter-heading">Stay Updated</h2>
<p>Get the latest articles delivered to your inbox.</p>
<form method="post" action="/newsletter/subscribe" novalidate>
<label for="newsletter-email" class="sr-only">Email address</label>
<input type="email" id="newsletter-email" name="email"
placeholder="your@email.com" required
aria-describedby="newsletter-hint" />
<span id="newsletter-hint" class="sr-only">We'll never share your email</span>
<button type="submit">Subscribe</button>
</form>
<div role="status" aria-live="polite" class="newsletter-status" hidden></div>
</section>
11.5 Related Content
<section aria-labelledby="related-posts-heading" class="related-posts">
<h2 id="related-posts-heading">Related Articles</h2>
<div class="card-grid">
<article class="card">
<a href="/blog/related-post" class="card-link" aria-label="Read: Related Post Title">
<img src="thumb.jpg" alt="" width="400" height="225" loading="lazy" />
<h3 class="card-title">Related Post Title</h3>
</a>
<p class="card-excerpt">Brief description...</p>
</article>
</div>
</section>
12. Performance & Core Web Vitals
12.1 LCP (Largest Contentful Paint) — Target ≤ 2.5s
Primary strategies for CMS themes:
- Optimize hero/featured images — WebP/AVIF, responsive srcset, explicit dimensions,
fetchpriority="high"on the hero - Eliminate render-blocking resources — inline critical CSS, defer full CSS with preload, async/defer JS
- Resource hints for critical origins:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="dns-prefetch" href="https://analytics.example.com">
<link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
Critical CSS pattern:
<style>
/* Above-the-fold styles: header, hero, navigation only */
header { ... }
.hero { ... }
nav { ... }
</style>
<link rel="preload" href="/css/theme.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
12.2 CLS (Cumulative Layout Shift) — Target ≤ 0.1
- Always declare image dimensions — width and height on every
<img> - Use
aspect-ratiofor dynamic/variable images:
.image-wrapper {
aspect-ratio: 16 / 9;
overflow: hidden;
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
}
- Use
font-display: swap— prevents FOIT which causes layout shifts when fonts load - Reserve space for dynamic content (ads, embeds):
.ad-container { min-height: 250px; width: 100%; }
- Don't insert content above existing content — reserve space or insert at the end
12.3 INP (Interaction to Next Paint) — Target ≤ 200ms
- Keep main thread responsive: break up long tasks (< 50ms)
- Defer non-critical JavaScript
- Use
requestAnimationFramefor visual updates - Lazy load below-the-fold content and images
- Minimize third-party script impact — delay or load only on interaction:
document.getElementById('open-chat')?.addEventListener('click', () => {
const script = document.createElement('script');
script.src = 'https://chat-widget.example.com/widget.js';
script.async = true;
document.body.appendChild(script);
}, { once: true });
12.4 Lazy Loading
<!-- Native lazy loading for below-fold images -->
<img src="photo.jpg" alt="..." loading="lazy" width="800" height="600" decoding="async">
<!-- For iframes (comments, embeds) -->
<iframe src="https://example.com/widget" loading="lazy" title="Widget"></iframe>
12.5 Performance Budget Reference
| Metric | Target |
|---|---|
| LCP | ≤ 2.5s |
| INP | ≤ 200ms |
| CLS | ≤ 0.1 |
| FCP | ≤ 1.8s |
| TBT | ≤ 200ms |
| Total page weight | ≤ 500KB |
| CSS | ≤ 50KB |
| JS | ≤ 100KB |
| Fonts | ≤ 50KB |
13. Modern CSS for Themes
13.1 Container Queries
Component-based responsiveness without knowing the viewport:
.card-grid > * {
container-type: inline-size;
container-name: card;
}
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-4);
}
}
@container card (min-width: 600px) {
.card__title { font-size: var(--step-2); }
}
Container query length units for fluid sizing inside containers:
cqi— 1% of container inline-sizecqw/cqh/cqmin/cqmax
13.2 The :has() Selector
Style parents based on their children — powerful for CMS where content varies:
/* Card that contains an image gets a different layout */
.card:has(img) {
grid-template-columns: 1fr 2fr;
}
/* Different layout when the list has more than 3 items */
.post-list:has(> :nth-child(4)) .post-item:first-child {
grid-column: 1 / -1;
}
/* Style a form field that's currently invalid */
.field:has(:invalid:not(:placeholder-shown)) .field-error {
display: block;
}
13.3 Logical Properties
Write once, work across writing modes (LTR, RTL, vertical):
.container {
margin-inline: auto; /* Instead of margin-left: auto; margin-right: auto */
padding-inline: var(--space-4); /* Instead of padding-left/right */
border-inline-start: 3px solid var(--color-accent); /* Instead of border-left */
padding-block: var(--space-6); /* Instead of padding-top/bottom */
}
Works for: dir="ltr" → margin-right, dir="rtl" → margin-left, RTL text automatically mirrored.
13.4 Subgrid
Align nested elements to the parent grid — keeps cards aligned in a grid even when content varies:
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-5);
}
.post-card {
display: grid;
grid-template-rows: subgrid; /* inherit parent row tracks */
grid-row: span 3; /* span 3 rows */
}
.post-card__meta {
align-self: end; /* all meta sections align to the bottom */
}
13.5 Scroll-Driven Animations
Only when user hasn't requested reduced motion:
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: no-preference) {
.animate-on-scroll {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
13.6 CSS Methodology Comparison
| Methodology | Approach | Best For |
|---|---|---|
| CUBE CSS | Composition → Utility → Block → Exception | CMS themes, progressive enhancement — leverages cascade, tiny CSS output |
| BEM | .block__element--modifier |
Component libraries, strict naming, large teams |
| Utility-first (Tailwind) | Atomic classes in HTML | Rapid prototyping, design systems |
| ITCSS | Specificity layers (Settings→Tools→Generic→Elements→Objects→Components→Trumps) | Large-scale applications |
For CMS themes, CUBE CSS is the recommended approach — it produces very little CSS, handles content variance gracefully, and composes well with @layer:
@layer composition, utilities, blocks, exceptions;
@import 'composition/_grid.css' layer(composition);
@import 'composition/_flow.css' layer(composition);
@import 'utilities/_tokens.css' layer(utilities);
@import 'blocks/_card.css' layer(blocks);
@import 'exceptions/_states.css' layer(exceptions);
14. Theme Testing & QA
14.1 Accessibility Automation
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should have no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
// Test specific components
test('navigation should be accessible', async ({ page }) => {
await page.goto('/');
const nav = page.locator('nav[aria-label="Main navigation"]');
const results = await new AxeBuilder({ page }).include(nav).analyze();
expect(results.violations).toEqual([]);
});
14.2 Visual Regression Testing
import { test, expect } from '@playwright/test';
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
});
});
test('dark mode visual regression', async ({ page }) => {
await page.goto('/');
await page.click('#theme-toggle');
await expect(page).toHaveScreenshot('homepage-dark.png', { fullPage: true });
});
14.3 Lighthouse CI
{
"ci": {
"collect": {
"numberOfRuns": 3,
"settings": { "preset": "desktop", "throttlingMethod": "simulate" }
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:seo": ["error", { "minScore": 0.9 }]
}
}
}
}
14.4 Manual QA Checklist
- Navigate entire site using only keyboard (Tab, Enter, Escape, Arrow keys)
- Test with screen reader (VoiceOver on macOS, NVDA on Windows)
- Test with browser zoom at 200%, 300%, 400%
- Verify focus indicators visible on all interactive elements
- Test all color combinations with WebAIM contrast checker
- Test with
prefers-reduced-motion: reduceenabled - Test with
prefers-color-scheme: darkenabled - Test forms with and without JavaScript
- Test print stylesheet
- Verify all images have appropriate
altattributes - 320px reflow — no horizontal scroll (WCAG 1.4.10)
- Touch targets ≥ 24×24 CSS px (WCAG 2.5.8)
- Works in both portrait and landscape orientations
14.5 CI Pipeline
name: Theme Quality
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- name: Lighthouse CI
run: npx @lhci/cli@0.14.x autorun
- name: Accessibility
run: npx playwright test --project=accessibility
- name: Visual regression
run: npx playwright test --project=visual
- name: Validate HTML
run: npx html-validate 'dist/**/*.html'