# Slide Deck Patterns
CSS patterns, JS engine, slide type layouts, transitions, navigation chrome, and curated presets for self-contained HTML slide presentations. All slides are viewport-fit (100dvh), single-file, same philosophy as scrollable pages.
**When to use slides:** Only when the user explicitly requests them — `/generate-slides`, `--slides` flag on an existing prompt, or natural language like "as a slide deck." Never auto-select slide format.
**Before generating**, also read `./css-patterns.md` for shared patterns (Mermaid zoom controls, overflow protection, depth tiers, status badges) and `./libraries.md` for Mermaid theming, Chart.js, and font pairings. Those patterns apply to slides too — this file adds slide-specific patterns on top.
## Planning a Deck from a Source Document
When converting a plan, spec, review, or any structured document into slides, follow this process before writing any HTML. Skipping it leads to polished-looking decks that silently drop 30–40% of the source material.
**Step 1 — Inventory the source.** Read the entire source document and enumerate every section, subsection, card, table row, decision, specification, collapsible detail, and footnote. Count them. A plan with 7 sections, 6 decision cards, a 7-row file table, 4 presets, 6 technique guides, and an engine spec with 3 sub-specs and 2 collapsibles is ~25 distinct content items that all need slide real estate.
**Step 2 — Map source to slides.** Assign each inventory item to one or more slides. Every item must appear somewhere. Rules:
- If a section has 6 decisions, all 6 need slides — not the 2 that fit on one split slide.
- If a table has 7 rows, all 7 rows show up.
- Collapsible/expandable details in the source are not optional in the deck — they become their own slides.
- Subsections with multiple cards (e.g., "6 Visual Technique cards") may need 2–3 slides to cover at readable density.
- Each plan section typically needs a divider slide + 1–3 content slides depending on density.
**Step 3 — Choose layouts.** For each planned slide, pick a slide type and spatial composition. Vary across the sequence (see Compositional Variety below). This is where narrative pacing happens — alternate dense slides with sparse ones.
**Step 4 — Plan images.** Run `which surf`. If surf-cli is available, plan 2–4 generated images for the deck. At minimum, target the **title slide** (16:9 background that sets the visual tone) and **one full-bleed slide** (immersive background for a key moment). Content slides with conceptual topics also benefit from a 1:1 illustration in the aside area. Generate these images early — before writing HTML — so you can embed them as base64 data URIs. See the Proactive Imagery section below for the full workflow. If surf isn't available, degrade to CSS gradients and SVG decorations — note the fallback in a comment but don't error.
**Step 5 — Verify before writing HTML.** Scan the inventory from Step 1. Is anything unmapped? Would a reader of the source document notice something missing from the deck? If yes, add slides. A source document with 7 sections typically produces 18–25 slides, not 10–13.
**The test:** After generating the deck, a reader who has never seen the source document should be able to reconstruct every major point from the slides alone. If they'd miss entire sections, the deck is incomplete.
## Slide Engine Base
The deck is a scroll-snap container. Each slide is exactly one viewport tall.
```html
... ... ...
```
```css
/* Scroll-snap container */
.deck {
height: 100dvh;
overflow-y: auto;
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
/* Individual slide */
.slide {
height: 100dvh;
scroll-snap-align: start;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(40px, 6vh, 80px) clamp(40px, 8vw, 120px);
isolation: isolate; /* contain z-index stacking */
}
```
## Typography Scale
Slide typography is 2–3× larger than scrollable pages. Page-sized text on a viewport-sized canvas looks like a mistake.
```css
.slide__display {
font-size: clamp(48px, 10vw, 120px);
font-weight: 800;
letter-spacing: -3px;
line-height: 0.95;
text-wrap: balance;
}
.slide__heading {
font-size: clamp(28px, 5vw, 48px);
font-weight: 700;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.slide__body {
font-size: clamp(16px, 2.2vw, 24px);
line-height: 1.6;
text-wrap: pretty;
}
.slide__label {
font-family: var(--font-mono);
font-size: clamp(10px, 1.2vw, 14px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
}
.slide__subtitle {
font-family: var(--font-mono);
font-size: clamp(14px, 1.8vw, 20px);
color: var(--text-dim);
letter-spacing: 0.5px;
}
```
| Element | Size range | Notes |
|---------|-----------|-------|
| Display (title slides) | 48–120px | `10vw` preferred, weight 800 |
| Section numbers | 100–240px | Ultra-light (weight 200), decorative |
| Headings | 28–48px | `5vw` preferred, weight 700 |
| Body / bullets | 16–24px | `2.2vw` preferred, 1.6 line-height |
| Code blocks | 14–18px | `1.8vw` preferred, mono |
| Quotes | 24–48px | `4vw` preferred, serif italic |
| Labels / captions | 10–14px | Mono, uppercase, dimmed |
## Cinematic Transitions
IntersectionObserver adds `.visible` when a slide enters the viewport. Slides animate in once and stay visible when scrolling back.
```css
/* Slide entrance — fade + lift + subtle scale */
.slide {
opacity: 0;
transform: translateY(40px) scale(0.98);
transition:
opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible {
opacity: 1;
transform: none;
}
/* Staggered child reveals — add .reveal to each content element */
.slide .reveal {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.slide.visible .reveal {
opacity: 1;
transform: none;
}
/* Stagger delays — up to 6 children per slide */
.slide.visible .reveal:nth-child(1) { transition-delay: 0.1s; }
.slide.visible .reveal:nth-child(2) { transition-delay: 0.2s; }
.slide.visible .reveal:nth-child(3) { transition-delay: 0.3s; }
.slide.visible .reveal:nth-child(4) { transition-delay: 0.4s; }
.slide.visible .reveal:nth-child(5) { transition-delay: 0.5s; }
.slide.visible .reveal:nth-child(6) { transition-delay: 0.6s; }
@media (prefers-reduced-motion: reduce) {
.slide,
.slide .reveal {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}
```
## Navigation Chrome
All navigation is `position: fixed` with high z-index, layered above slides. Styled to be visible on any background.
### Progress Bar
```css
.deck-progress {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: var(--accent);
z-index: 100;
transition: width 0.3s ease;
pointer-events: none;
}
```
### Nav Dots
```css
.deck-dots {
position: fixed;
right: clamp(12px, 2vw, 24px);
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 100;
}
.deck-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
opacity: 0.3;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.deck-dot:hover {
opacity: 0.6;
}
.deck-dot.active {
opacity: 1;
transform: scale(1.5);
background: var(--accent);
}
```
### Slide Counter
```css
.deck-counter {
position: fixed;
bottom: clamp(12px, 2vh, 24px);
right: clamp(12px, 2vw, 24px);
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
z-index: 100;
font-variant-numeric: tabular-nums;
}
```
### Keyboard Hints
Auto-fade after first interaction or after 4 seconds.
```css
.deck-hints {
position: fixed;
bottom: clamp(12px, 2vh, 24px);
left: 50%;
transform: translateX(-50%);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
opacity: 0.6;
z-index: 100;
transition: opacity 0.5s ease;
white-space: nowrap;
}
.deck-hints.faded {
opacity: 0;
pointer-events: none;
}
```
### Chrome Visibility on Mixed Backgrounds
For decks where some slides are light and some dark (especially full-bleed slides), nav chrome needs to remain visible. Two approaches:
```css
/* Approach A: subtle backdrop on chrome elements */
.deck-dots,
.deck-counter {
background: color-mix(in srgb, var(--bg) 70%, transparent 30%);
padding: 6px;
border-radius: 20px;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Approach B: text shadow for legibility on any background */
.deck-counter,
.deck-hints {
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
```
## SlideEngine JavaScript
Add once at the end of the page. Handles navigation, chrome updates, and scroll-triggered reveals. Event delegation ensures slide-internal interactions (Mermaid zoom, scrollable code, overflow tables) don't trigger slide navigation.
```javascript
class SlideEngine {
constructor() {
this.deck = document.querySelector('.deck');
this.slides = [...document.querySelectorAll('.slide')];
this.current = 0;
this.total = this.slides.length;
this.buildChrome();
this.bindEvents();
this.observe();
this.update();
}
buildChrome() {
// Progress bar
var bar = document.createElement('div');
bar.className = 'deck-progress';
document.body.appendChild(bar);
this.bar = bar;
// Nav dots
var dots = document.createElement('div');
dots.className = 'deck-dots';
var self = this;
this.slides.forEach(function(_, i) {
var d = document.createElement('button');
d.className = 'deck-dot';
d.title = 'Slide ' + (i + 1);
d.onclick = function() { self.goTo(i); };
dots.appendChild(d);
});
document.body.appendChild(dots);
this.dots = [].slice.call(dots.children);
// Counter
var ctr = document.createElement('div');
ctr.className = 'deck-counter';
document.body.appendChild(ctr);
this.counter = ctr;
// Keyboard hints
var hints = document.createElement('div');
hints.className = 'deck-hints';
hints.textContent = '\u2190 \u2192 or scroll to navigate';
document.body.appendChild(hints);
this.hints = hints;
this.hintTimer = setTimeout(function() {
hints.classList.add('faded');
}, 4000);
}
bindEvents() {
var self = this;
// Keyboard — skip if focus is inside interactive content
document.addEventListener('keydown', function(e) {
if (e.target.closest('.mermaid-wrap, .table-scroll, .code-scroll, input, textarea, [contenteditable]')) return;
if (['ArrowDown', 'ArrowRight', ' ', 'PageDown'].includes(e.key)) {
e.preventDefault(); self.next();
} else if (['ArrowUp', 'ArrowLeft', 'PageUp'].includes(e.key)) {
e.preventDefault(); self.prev();
} else if (e.key === 'Home') {
e.preventDefault(); self.goTo(0);
} else if (e.key === 'End') {
e.preventDefault(); self.goTo(self.total - 1);
}
self.fadeHints();
});
// Touch swipe
var touchY;
this.deck.addEventListener('touchstart', function(e) {
touchY = e.touches[0].clientY;
}, { passive: true });
this.deck.addEventListener('touchend', function(e) {
var dy = touchY - e.changedTouches[0].clientY;
if (Math.abs(dy) > 50) { dy > 0 ? self.next() : self.prev(); }
});
}
observe() {
var self = this;
var obs = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
self.current = self.slides.indexOf(entry.target);
self.update();
}
});
}, { threshold: 0.5 });
this.slides.forEach(function(s) { obs.observe(s); });
}
goTo(i) {
this.slides[Math.max(0, Math.min(i, this.total - 1))]
.scrollIntoView({ behavior: 'smooth' });
}
next() { if (this.current < this.total - 1) this.goTo(this.current + 1); }
prev() { if (this.current > 0) this.goTo(this.current - 1); }
update() {
this.bar.style.width = ((this.current + 1) / this.total * 100) + '%';
var self = this;
this.dots.forEach(function(d, i) { d.classList.toggle('active', i === self.current); });
this.counter.textContent = (this.current + 1) + ' / ' + this.total;
}
fadeHints() {
clearTimeout(this.hintTimer);
this.hints.classList.add('faded');
}
}
```
**Usage:** Instantiate after the DOM is ready and any libraries (Mermaid, Chart.js) have rendered. Always call `autoFit()` before `new SlideEngine()` so content is sized correctly before intersection observers fire.
```html
```
## Auto-Fit
A single post-render function that handles all known content overflow cases. Agents can't perfectly predict how text reflows at every viewport size, so `autoFit()` is a required safety net. Call it after Mermaid/Chart.js render but before SlideEngine init.
```javascript
function autoFit() {
// Mermaid SVGs: fill container instead of rendering at intrinsic size
document.querySelectorAll('.mermaid svg').forEach(function(svg) {
svg.removeAttribute('height');
svg.style.width = '100%';
svg.style.maxWidth = '100%';
svg.style.height = 'auto';
svg.parentElement.style.width = '100%';
});
// KPI values: visually scale down text that overflows card width
document.querySelectorAll('.slide__kpi-val').forEach(function(el) {
if (el.scrollWidth > el.clientWidth) {
var s = el.clientWidth / el.scrollWidth;
el.style.transform = 'scale(' + s + ')';
el.style.transformOrigin = 'left top';
}
});
// Blockquotes: reduce font proportionally for long text
document.querySelectorAll('.slide--quote blockquote').forEach(function(el) {
var len = el.textContent.trim().length;
if (len > 100) {
var scale = Math.max(0.5, 100 / len);
var fs = parseFloat(getComputedStyle(el).fontSize);
el.style.fontSize = Math.max(16, Math.round(fs * scale)) + 'px';
}
});
}
```
Three cases, one function:
- **Mermaid:** SVGs render with fixed dimensions inside flex containers — force them to fill available width.
- **KPI values:** Long text strings at hero scale overflow card boundaries — `transform: scale()` shrinks visually without reflow.
- **Blockquotes:** Quotes longer than ~100 characters get proportionally smaller font. The 0.5 floor prevents unreadably small text; if it needs more than 50% shrink, it should have been a content slide.
## Slide Type Layouts
Each type has a defined HTML structure and CSS layout. The agent can adapt colors, fonts, and spacing per aesthetic, but the structural patterns stay consistent.
### Title Slide
Full-viewport hero. Background treatment via gradient, texture, or surf-generated image. 80–120px display type.
```html
Deck Title
Subtitle or date
```
```css
.slide--title {
justify-content: center;
align-items: center;
text-align: center;
}
```
### Section Divider
Oversized decorative number (200px+, ultra-light weight) with heading. Breathing room between topics. SVG accent marks optional.
```html
02
```
```css
.slide--content .slide__inner {
display: grid;
grid-template-columns: 3fr 2fr;
gap: clamp(24px, 4vw, 60px);
align-items: center;
width: 100%;
}
/* For right-heavy variant: swap to 2fr 3fr */
.slide--content .slide__bullets {
list-style: none;
padding: 0;
}
.slide--content .slide__bullets li {
padding: 8px 0 8px 20px;
position: relative;
font-size: clamp(16px, 2vw, 22px);
line-height: 1.6;
color: var(--text-dim);
}
.slide--content .slide__bullets li::before {
content: '';
position: absolute;
left: 0;
top: 18px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
```
### Split Slide
Asymmetric two-panel (60/40 or 70/30). Before/after, text+diagram, text+image. Each panel has its own background tier. Zero padding on the slide itself — panels fill edge to edge.
```html
Left Panel
Content...
```
```css
.slide--split {
padding: 0;
}
.slide--split .slide__panels {
display: grid;
grid-template-columns: 3fr 2fr;
height: 100%;
}
.slide--split .slide__panel {
padding: clamp(40px, 6vh, 80px) clamp(32px, 4vw, 60px);
display: flex;
flex-direction: column;
justify-content: center;
}
.slide--split .slide__panel--primary {
background: var(--surface);
}
.slide--split .slide__panel--secondary {
background: var(--surface2);
}
```
### Diagram Slide
Full-viewport Mermaid diagram. Max 8–10 nodes (presentation scale — fewer, larger than page diagrams). Node labels at 18px+, edges at 2px+. Zoom controls from `css-patterns.md` apply here.
**When to use Mermaid vs CSS in slides.** Mermaid renders SVGs at a fixed size the agent can't control — node dimensions are set by the library, not by CSS. This creates a recurring problem: small diagrams (fewer than ~7 nodes, no branching) render as tiny elements floating in a huge viewport with acres of dead space. The rule:
- **Use Mermaid** for complex graphs: 8+ nodes, branching paths, cycles, multiple edge crossings — anything where automatic edge routing saves real effort.
- **Use CSS Pipeline** (below) for simple linear flows: A → B → C → D sequences, build steps, deployment stages. CSS cards give full control over sizing, typography, and fill the viewport naturally.
- **Never leave a small Mermaid diagram alone on a slide.** If the diagram is small, either switch to CSS, or pair it with supporting content (description cards, bullet annotations, a summary panel) in a split layout. A slide with a tiny diagram and empty space is a failed slide.
**Mermaid centering fix.** When you do use Mermaid, add `display: flex; align-items: center; justify-content: center;` to `.mermaid-wrap` so the SVG centers within its container instead of hugging the top-left corner. Change `transform-origin` to `center center` so zoom radiates from the middle.
```html
Diagram Title
graph TD
A --> B
```
**Click to expand.** Clicking anywhere on the diagram (without dragging) opens it full-size in a new browser tab. The expand button (⛶) provides the same functionality for discoverability.
```css
.slide--diagram {
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px);
}
.slide--diagram .slide__heading {
margin-bottom: clamp(8px, 1.5vh, 20px);
}
.slide--diagram .mermaid-wrap {
border-radius: 12px;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
}
.slide--diagram .mermaid-wrap .mermaid {
transform-origin: center center;
}
```
**Auto-fit SVG to container.** Mermaid renders SVGs with fixed dimensions and an inline `max-width` style that keeps diagrams tiny inside large slides. The `autoFit()` function (see above) handles this at runtime. Keep the CSS as a belt-and-suspenders fallback:
```css
.slide--diagram .mermaid svg {
width: 100% !important;
height: auto !important;
max-width: 100% !important;
}
```
**Mermaid overrides for presentation scale** (add alongside the standard Mermaid CSS overrides from `libraries.md`):
```css
.slide--diagram .mermaid .nodeLabel {
font-size: 18px !important;
}
.slide--diagram .mermaid .edgeLabel {
font-size: 14px !important;
}
.slide--diagram .mermaid .node rect,
.slide--diagram .mermaid .node circle,
.slide--diagram .mermaid .node polygon {
stroke-width: 2px;
}
.slide--diagram .mermaid .edge-pattern-solid {
stroke-width: 2px;
}
```
### CSS Pipeline Slide
For simple linear flows (build steps, deployment stages, data pipelines) where Mermaid would render too small. CSS cards with arrow connectors give full control over sizing and fill the viewport naturally. Each step card expands to fill available space via `flex: 1`.
```html
Pipeline Label
Pipeline Title
01
Step Name
What this step produces or does
output-file.md
...
```
```css
.pipeline {
display: flex;
align-items: stretch;
gap: 0;
flex: 1;
min-height: 0;
margin-top: clamp(12px, 2vh, 24px);
}
.pipeline__step {
flex: 1;
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: clamp(14px, 2.5vh, 28px) clamp(12px, 1.5vw, 22px);
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: break-word;
}
.pipeline__num {
font-size: clamp(10px, 1.2vw, 13px);
font-weight: 600;
color: var(--accent);
letter-spacing: 1px;
}
.pipeline__name {
font-size: clamp(16px, 2vw, 24px);
font-weight: 700;
margin: clamp(4px, 0.8vh, 8px) 0;
}
.pipeline__desc {
font-size: clamp(12px, 1.3vw, 16px);
color: var(--text-dim);
line-height: 1.5;
flex: 1;
}
.pipeline__file {
font-size: clamp(10px, 1.1vw, 12px);
color: var(--accent);
background: var(--accent-dim);
padding: 3px 8px;
border-radius: 4px;
margin-top: clamp(8px, 1.5vh, 16px);
align-self: flex-start;
}
.pipeline__arrow {
display: flex;
align-items: center;
padding: 0 clamp(3px, 0.4vw, 6px);
color: var(--accent);
flex-shrink: 0;
opacity: 0.4;
}
@media (max-width: 768px) {
.pipeline { flex-direction: column; }
.pipeline__arrow { justify-content: center; padding: 4px 0; transform: rotate(90deg); }
}
```
Each `.pipeline__step` uses `flex: 1` to fill available width equally, and the pipeline container itself uses `flex: 1` to fill available vertical space in the slide. Step cards stretch to fill, so the content isn't floating in empty space. The `.pipeline__file` badge at the bottom anchors each card and adds a practical detail. Max 5–6 steps — beyond that, split across two slides.
### Dashboard Slide
KPI cards at presentation scale (48–64px hero numbers). Mini-charts via Chart.js or SVG sparklines. Max 6 KPIs.
```html
Metrics Overview
247
Lines Added
```
```css
.slide--dashboard .slide__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 20vw, 220px), 1fr));
gap: clamp(12px, 2vw, 24px);
}
.slide__kpi {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: clamp(16px, 3vh, 32px) clamp(16px, 2vw, 24px);
min-width: 0;
overflow: hidden;
}
.slide__kpi-val {
font-size: clamp(36px, 6vw, 64px);
font-weight: 800;
letter-spacing: -1.5px;
line-height: 1.1;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.slide__kpi-label {
font-family: var(--font-mono);
font-size: clamp(9px, 1.2vw, 13px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-dim);
margin-top: 8px;
}
```
**KPI hero values should be short** — numbers, percentages, 1–3 word labels. Ideal length is 1–6 characters at hero scale. Longer strings like `store=false` break the layout at 64px. If you must show a longer value, put it in the label or body text instead. The `autoFit()` function (see below) will scale down overflows as a safety net.
### Table Slide
18–20px cell text for projection readability. Max 8 rows per slide — overflow paginates to the next slide. Stronger alternating row contrast than page tables.
```html
Data Title
...
```
```css
.slide--table {
padding: clamp(24px, 4vh, 48px) clamp(24px, 4vw, 60px);
}
.slide--table .data-table {
font-size: clamp(14px, 1.8vw, 20px);
}
.slide--table .data-table th {
font-size: clamp(10px, 1.3vw, 14px);
padding: clamp(8px, 1.5vh, 14px) clamp(12px, 2vw, 20px);
}
.slide--table .data-table td {
padding: clamp(10px, 1.5vh, 16px) clamp(12px, 2vw, 20px);
}
```
### Code Slide
18px mono on a recessed dark background. Max 10 lines. Floating filename label. Centered on the viewport for focus.
```html
What Changed
worker.ts
function processQueue(items) {
// highlighted code here
}