How to Build an Illustrated Scrolling Storyboard
Turn a grid of AI-generated panels into a cinematic scroll-driven novelette. Ken Burns push-in, parallax, chapter pacing, keyboard navigation, progress HUD, fullscreen, and a city-map mini-map. About 600 lines of HTML/CSS/JS, no build step, no dependencies.
✍️ Note from the Creator
Most authors have cover art and maybe a chapter illustration. With AI-generated panels, an indie author can produce 28+ frames in an afternoon, a full visual short story. The problem is presentation. A flat grid of thumbnails on a webpage reads as "concept art dump." A scroll-driven cinematic reads as a film.
This is the build I used for Dariah: Night in New Lancaster, a free 28-panel side-story for The Roar of Winchester. The full source is small enough that you can lift it into any indie author or comic site in under an hour.
The technique works for anything sequential: a graphic-novel short, a recipe walkthrough, a product story, an annual report. Any 20-60 image set with a narrative order benefits from this presentation.
⚙️ Prerequisites
- An image generator that produces consistent characters (Imagen 4, Nano Banana, Midjourney with Character Reference, or a Character Board)
- A static-file web host (any will do, the entire experience is one HTML file plus a folder of JPGs)
imagemagickfor splitting 2x2 sheets into individual panels- Optional:
cwebpfor compressing the panel images further
🎬 End Result
This is the live build. Scroll inside the frame to step through the 28 panels.
1 Generate the Panels
Generate panels in 2x2 sheets, four panels per sheet. A 28-panel storyboard is seven sheets. Most image generators produce a 2x2 grid more reliably than four separate calls because the model can keep the character consistent across all four positions in one shot.
Character consistency
The single biggest factor in whether a storyboard reads as one story versus four random images is whether the protagonist looks like the same person across panels. Three approaches that work:
- Character Board / Character Reference — Google Imagen 4 and Midjourney both accept a reference image of the character. Provide a clean front-facing portrait and the model anchors hair, face, outfit across generations.
- Locked-in outfit description — Re-state outfit and hair every prompt. "Young woman in her mid-20s with chin-length dark wavy hair, dusty rose knit beanie, charcoal grey wool trench coat, grey cable knit sweater underneath." The exact phrasing across all four panels of the sheet matters.
- Same generation, four scenes — Ask the model for a 2x2 grid of scenes featuring the same character. The model has to internally maintain consistency, which it does well at the cost of resolution per panel.
Aspect ratio
Each panel reads best at 16:9 widescreen. A 2x2 grid at 2752x1536 yields four 1376x768 panels. That's the resolution the cropping pipeline assumes. If you generate at a different size, the crop coordinates in Step 2 need to change proportionally.
2 Crop to Individual Panels
ImageMagick splits each 2x2 sheet into four quadrants. Save the script and run it whenever a sheet is regenerated.
#!/bin/bash
# Split 2x2 storyboard sheets into individual panels.
# Each sheet is 2752x1536; each panel is 1376x768.
OUT=./panels
mkdir -p $OUT
# Sheet → panel mapping. Update filenames + panel numbers per project.
declare -A SHEETS=(
["sheet-01.jpg"]="1 2 3 4"
["sheet-02.jpg"]="5 6 7 8"
["sheet-03.jpg"]="9 10 11 12"
["sheet-04.jpg"]="13 14 15 16"
["sheet-05.jpg"]="17 18 19 20"
["sheet-06.jpg"]="21 22 23 24"
["sheet-07.jpg"]="25 26 27 28"
)
# Quadrant offsets in reading order: TL, TR, BL, BR
OFFSETS=("+0+0" "+1376+0" "+0+768" "+1376+768")
for sheet in "${!SHEETS[@]}"; do
read -ra panels <<< "${SHEETS[$sheet]}"
for i in 0 1 2 3; do
n=$(printf "%02d" "${panels[$i]}")
convert "$sheet" -crop "1376x768${OFFSETS[$i]}" +repage "$OUT/panel-${n}.jpg" &
done
done
wait
echo "Done. $(ls $OUT/*.jpg | wc -l) panels written."
& at the end of each convert call backgrounds it. 28 panels crop in roughly the time of one. The wait at the end ensures the script doesn't exit until all crops finish.
Optional: WebP compression
JPG at quality 85 lands around 150-250KB per panel. WebP at quality 80 cuts that roughly in half. For 28 panels that's a 4MB total vs 8MB. Worth it.
for f in panels/panel-*.jpg; do
cwebp -q 80 -m 6 "$f" -o "${f%.jpg}.webp"
done
3 HTML & CSS Scaffold
Every panel is its own full-viewport section inside a scroll-snap container. The image is positioned with background-image on a centered div sized to the panel's aspect ratio. The CSS letterboxes the panel with black bars top and bottom for the cinemascope look.
<section class="panel" data-panel="1" data-chapter="I">
<div class="panel-frame">
<div class="panel-img"
style="background-image:url('panels/panel-01.jpg')"></div>
<div class="prose prose-bl">
<span class="prose-meta">Panel 01</span>
Your caption text here.
</div>
</div>
</section>
body{background:#000;scroll-snap-type:y mandatory;overflow-y:scroll}
.panel{
position:relative;height:100vh;width:100%;
display:flex;align-items:center;justify-content:center;
background:#000;overflow:hidden;
scroll-snap-align:start;
}
.panel-img{
position:absolute;top:50%;left:50%;
width:100%;max-width:1400px;
aspect-ratio:1376/768;
transform:translate(-50%,-50%) scale(1);
background-size:cover;background-position:center;
transition:transform 1.6s cubic-bezier(.22,.61,.36,1);
box-shadow:0 30px 100px rgba(0,0,0,0.8);
}
/* Ken Burns push-in when panel enters viewport */
.panel.in-view .panel-img{
transform:translate(-50%,-50%) scale(1.06);
}
/* Cinemascope vignette */
.panel-img::before{
content:'';position:absolute;inset:0;
background:radial-gradient(ellipse at center,
transparent 50%,rgba(0,0,0,0.45) 100%);
pointer-events:none;
}
/* Prose overlay */
.prose{
position:absolute;z-index:3;max-width:520px;
font-family:'Cormorant Garamond',serif;
font-size:clamp(1.1rem,1.6vw,1.45rem);
line-height:1.55;color:#f4ede0;
padding:1.5rem 2rem;
background:linear-gradient(180deg,
rgba(0,0,0,0),rgba(0,0,0,0.55));
backdrop-filter:blur(2px);
opacity:0;transform:translateY(20px);
transition:opacity 1.2s ease 0.4s,
transform 1.2s ease 0.4s;
text-shadow:0 2px 8px rgba(0,0,0,0.8);
}
.panel.in-view .prose{opacity:1;transform:translateY(0)}
.prose-bl{bottom:8vh;left:5vw}
.prose-br{bottom:8vh;right:5vw;text-align:right}
.prose-tl{top:10vh;left:5vw}
.prose-tr{top:10vh;right:5vw;text-align:right}
Three things to notice:
scroll-snap-type: y mandatoryon the body forces the browser to lock to each panel as the user scrolls. Without it the experience feels like an infinite-scroll feed instead of a film.- Ken Burns is a single CSS transform that runs only when the panel has the
.in-viewclass. The class is added by IntersectionObserver in Step 4. - The prose has four positional variants (bottom-left, bottom-right, top-left, top-right). Mix them across panels for visual rhythm so the eye doesn't keep landing in the same corner.
Chapter dividers
Between groups of panels, drop in a non-panel section that breaks the rhythm. This is what makes the experience feel structured instead of flat.
<section class="chapter">
<div class="chapter-num">Chapter II</div>
<h2 class="chapter-title">The City</h2>
<div class="chapter-rule"></div>
</section>
<style>
.chapter{height:70vh;display:flex;flex-direction:column;
align-items:center;justify-content:center;
background:#000;scroll-snap-align:start}
.chapter-num{font-size:.7rem;letter-spacing:.6em;
text-transform:uppercase;color:#8a8378;margin-bottom:1.5rem}
.chapter-title{font-family:'Cormorant Garamond',serif;
font-size:clamp(2.5rem,6vw,4.5rem);font-weight:300;
font-style:italic;color:#e8e2d8}
.chapter-rule{width:4rem;height:1px;background:#e85a9c;
margin:2rem auto 0}
</style>
4 The Scroll Engine
IntersectionObserver detects when each panel locks into the viewport and toggles the .in-view class. That single class triggers the Ken Burns push-in and the prose fade-in via CSS. No animation libraries, no requestAnimationFrame loop.
const panels = document.querySelectorAll('.panel');
const hudPanel = document.getElementById('hudPanel');
const progressBar = document.getElementById('progressBar');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('in-view');
const num = entry.target.dataset.panel;
if (num) {
hudPanel.textContent = String(num).padStart(2,'0') + ' / 28';
progressBar.style.width = (parseInt(num) / 28 * 100) + '%';
}
} else {
entry.target.classList.remove('in-view');
}
});
}, { threshold: 0.4 });
panels.forEach(p => observer.observe(p));
Chapter pacing
For chapters that should feel slower (an emotional beat, a quiet moment), add a .bridge class to those panels and slow their CSS transition. The reader feels the pacing shift even without realizing why.
.panel.bridge .panel-img{transition:transform 3s ease-out}
.panel.bridge.in-view .panel-img{
transform:translate(-50%,-50%) scale(1.04);
}
5 Navigation Controls
Scroll-snap on its own is fine on desktop but feels sluggish on long scrolls. Adding keyboard, touch-swipe, and clickable progress-bar nav makes the experience feel deliberate at any input device.
Keyboard navigation
const navSections = Array.from(
document.querySelectorAll('[data-nav]')
);
function navigateTo(direction) {
let target = null;
if (direction === 'next') {
for (const sec of navSections) {
if (sec.getBoundingClientRect().top > 50) { target = sec; break; }
}
} else {
for (let i = navSections.length - 1; i >= 0; i--) {
if (navSections[i].getBoundingClientRect().top < -50) {
target = navSections[i]; break;
}
}
}
if (target) target.scrollIntoView({behavior:'smooth',block:'start'});
}
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return;
switch(e.key) {
case 'ArrowDown': case 'ArrowRight':
case 'PageDown': case ' ':
e.preventDefault(); navigateTo('next'); break;
case 'ArrowUp': case 'ArrowLeft': case 'PageUp':
e.preventDefault(); navigateTo('prev'); break;
case 'Home':
e.preventDefault();
window.scrollTo({top:0,behavior:'smooth'}); break;
case 'End':
e.preventDefault();
window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}); break;
}
});
Each navigable section (panels, chapter dividers, title, end screen) needs the data-nav attribute. The function walks the array to find the next-or-previous section relative to current scroll position.
Touch swipe (mobile)
let touchStartY = 0, touchStartX = 0, touchStartTime = 0;
document.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
touchStartX = e.touches[0].clientX;
touchStartTime = Date.now();
}, { passive: true });
document.addEventListener('touchend', (e) => {
const dy = e.changedTouches[0].clientY - touchStartY;
const dx = e.changedTouches[0].clientX - touchStartX;
const dt = Date.now() - touchStartTime;
// Quick decisive vertical swipe (>50px, <500ms, mostly vertical)
if (dt < 500 && Math.abs(dy) > 50 && Math.abs(dy) > Math.abs(dx) * 1.5) {
if (dy < 0) navigateTo('next');
else navigateTo('prev');
}
}, { passive: true });
preventDefault on touch events. Browsers warn about non-passive touch handlers, and the user's native scroll-snap will fight any custom touch handling. The pattern above augments scroll-snap (jumping one section on a quick swipe) rather than replacing it.
Clickable progress bar
A thin progress bar at the top of the page doubles as a navigation control. Click anywhere on it to jump to that panel. Hover shows a tooltip with the panel number.
const progressEl = document.getElementById('progress');
progressEl.addEventListener('click', (e) => {
const rect = progressEl.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
const targetPanel = Math.max(1, Math.min(28, Math.ceil(pct * 28)));
const panelEl = document.querySelector(
`.panel[data-panel="${targetPanel}"]`
);
if (panelEl) panelEl.scrollIntoView({behavior:'smooth',block:'start'});
});
6 Polish Layers
Three optional details that take the experience from "scroll-snapping image gallery" to "this is intentional."
Mouse parallax (desktop)
Move the panel image a few pixels opposite the cursor position. Subtle, but it adds a 3D feel without nausea.
const supportsHover = window.matchMedia('(hover: hover)').matches;
if (supportsHover) {
panels.forEach(panel => {
const img = panel.querySelector('.panel-img');
panel.addEventListener('mousemove', (e) => {
if (!panel.classList.contains('in-view')) return;
const rect = panel.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
const tx = -x * 14;
const ty = -y * 10;
img.style.transform =
`translate(calc(-50% + ${tx}px),calc(-50% + ${ty}px)) scale(1.08)`;
});
panel.addEventListener('mouseleave', () => {
img.style.transform = '';
});
});
}
The matchMedia('(hover: hover)') check skips this on touch devices where it would only cause jank.
Snow / particle overlay
A pure-CSS particle layer adds ambient motion behind everything without a single JS frame. Pre-baked radial gradients at random positions, animated with a background-position shift.
.snow{
position:fixed;inset:0;pointer-events:none;z-index:10;
background-image:
radial-gradient(2px 2px at 10% 20%,rgba(255,255,255,0.7),transparent),
radial-gradient(1.5px 1.5px at 30% 60%,rgba(255,255,255,0.55),transparent),
radial-gradient(2.5px 2.5px at 70% 30%,rgba(255,255,255,0.65),transparent),
radial-gradient(1px 1px at 85% 75%,rgba(255,255,255,0.5),transparent),
radial-gradient(2px 2px at 50% 45%,rgba(255,255,255,0.6),transparent);
background-size:100% 100%;
animation:snowDrift 60s linear infinite;
opacity:0.4;
}
@keyframes snowDrift{
from{background-position:0 0,0 0,0 0,0 0,0 0}
to{background-position:50px 800px,-30px 900px,40px 850px,-20px 1000px,30px 950px}
}
Fullscreen toggle
One button in the corner toggles document fullscreen. Browsers handle the rest.
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(console.log);
} else {
document.exitFullscreen();
}
}
document.getElementById('fsBtn').addEventListener('click', toggleFullscreen);
document.addEventListener('keydown', (e) => {
if (e.key === 'f' || e.key === 'F') toggleFullscreen();
});
7 Deploy
Static hosting. Nothing fancy needed.
- Final folder structure:
folder layout
storyboard/ index.html # single self-contained file panels/ panel-01.jpg panel-02.jpg ... panel-28.jpg - Total size: ~5MB with WebP, ~10MB with JPG. Acceptable for a one-page experience.
- Add
<link rel="preload" as="image" href="panels/panel-01.jpg">for the first 3-4 panels in<head>. The rest browser-fetch as the user scrolls. The first paint is fast. - If served behind Cloudflare or another CDN, purge the path after deploys so the next visitor gets the latest version.
⊞ How It Works
The whole system is three layers:
- Scroll-snap handles positioning. The browser locks each panel into the viewport as the user scrolls. No JS involved in this layer.
- IntersectionObserver watches each panel. When one becomes visible, it gets a CSS class. When it leaves, the class is removed. Everything visual (Ken Burns, prose fade, HUD update, progress bar, map dot) keys off that single class.
- Optional control surfaces (keyboard, swipe, progress click, fullscreen) call
scrollIntoViewon the next/previous section. Scroll-snap then takes over again.
That's the architecture. The fancy presentation (parallax, snow, chapter pacing, map HUD) is all decorative CSS layered on top of those three primitives. Lifting just the three primitives gives a working scrolling storyboard. Each polish layer is independent and can be dropped without breaking anything else.
? Troubleshooting
Scroll-snap feels sluggish
Use scroll-snap-type: y mandatory, not y proximity. Mandatory locks every panel. Proximity leaves the door open for the browser to skip, which feels broken on this kind of layout.
Ken Burns flickers on Safari
Add will-change: transform to .panel-img. Safari otherwise composites the transform on the CPU which causes the flicker.
Prose overlap on mobile
The four prose positions (TL/TR/BL/BR) reposition to a single full-width block on screens narrower than 768px. Always test mobile separately; what looks balanced on desktop can stack awkwardly.
Touch swipe fights scroll-snap
Use {passive: true} on the touchstart/touchend listeners and only trigger custom navigation on a quick, decisive vertical swipe (>50px in <500ms). Long or slow swipes should fall through to native scroll-snap.
Images load slowly on the first panel
Preload the first 3-4 panels in <head> with <link rel="preload" as="image">. The remaining panels can lazy-load naturally as the user approaches them.
The fullscreen button doesn't work
The Fullscreen API requires a user gesture. Bind the toggle to a click handler, not to page load or scroll. Some browsers also block fullscreen inside iframes; if the storyboard is embedded somewhere, fullscreen will only work when opened in its own tab.