Comics — Guided View (panel-by-panel transitions)
A Marvel-style guided-view comic reader for the fictional series Neon Ronin, presenting one panel at a time full-bleed. A virtual page canvas holds ink-bordered panels with halftone texture, speech balloons, and bold SFX lettering; JavaScript computes a fit-to-viewport transform per panel and smoothly pans and zooms the camera between them. Progress dots track panels on the current page, a page label and issue scrub show position, and an autoplay toggle auto-advances every few seconds. Keyboard arrows, edge hotzones, and tappable dots all drive navigation.
MCP
Код
:root {
--ink: #0e0e12;
--ink-2: #23232b;
--paper: #fdfcf7;
--panel: #ffffff;
--accent: #ff2e4d;
--accent-2: #ffd23f;
--accent-blue: #2e6bff;
--muted: #6b6b78;
--line: rgba(14, 14, 18, 0.14);
--line-2: rgba(14, 14, 18, 0.28);
--halftone: radial-gradient(circle, rgba(14, 14, 18, 0.18) 1px, transparent 1.6px);
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
/* dark reader chrome */
--chrome: #121218;
--chrome-2: #1c1c25;
--chrome-line: rgba(255, 255, 255, 0.12);
--chrome-text: #f1eff7;
--chrome-muted: #9a9aac;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
color: var(--chrome-text);
background: var(--chrome);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ---------- Reader shell ---------- */
.reader {
display: grid;
grid-template-rows: auto 1fr auto auto;
height: 100dvh;
background:
radial-gradient(120% 80% at 50% -10%, #1f1f2b, var(--chrome));
}
/* ---------- Bars ---------- */
.bar {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 16px;
background: var(--chrome-2);
border-bottom: 1px solid var(--chrome-line);
z-index: 5;
}
.bar--bottom {
border-bottom: none;
border-top: 1px solid var(--chrome-line);
flex-wrap: wrap;
}
.series {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.series__badge {
flex: none;
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: var(--r-sm);
border: 2px solid var(--ink);
background: var(--accent-2);
color: var(--ink);
font-family: "Bangers", system-ui, sans-serif;
font-size: 1.25rem;
letter-spacing: 1px;
box-shadow: 3px 3px 0 var(--ink);
}
.series__meta {
display: flex;
flex-direction: column;
min-width: 0;
}
.series__title {
font-family: "Bangers", system-ui, sans-serif;
font-size: 1.4rem;
letter-spacing: 1.5px;
line-height: 1;
color: var(--accent-2);
}
.series__issue {
font-size: 0.8rem;
color: var(--chrome-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pagelabel {
margin-inline: auto;
display: flex;
align-items: baseline;
gap: 6px;
font-size: 0.85rem;
color: var(--chrome-muted);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.pagelabel strong {
font-family: "Bangers", system-ui, sans-serif;
font-size: 1.3rem;
letter-spacing: 1px;
color: var(--chrome-text);
}
.pagelabel__sep {
opacity: 0.5;
}
.bar__actions {
display: flex;
gap: 8px;
}
/* ---------- Buttons ---------- */
.btn {
font-family: "Inter", sans-serif;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.3px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: var(--r-sm);
border: 2px solid var(--ink);
cursor: pointer;
transition: transform 0.08s var(--ease), box-shadow 0.08s var(--ease),
background 0.15s ease, color 0.15s ease;
box-shadow: 3px 3px 0 var(--ink);
}
.btn:active {
transform: translate(3px, 3px);
box-shadow: 0 0 0 var(--ink);
}
.btn:focus-visible {
outline: 3px solid var(--accent-blue);
outline-offset: 2px;
}
.btn--accent {
background: var(--accent);
color: #fff;
}
.btn--accent:hover {
background: #ff1d3f;
}
.btn--ink {
background: var(--panel);
color: var(--ink);
}
.btn--ink:hover {
background: var(--accent-2);
}
.btn--ghost {
background: transparent;
color: var(--chrome-text);
border-color: var(--chrome-line);
box-shadow: none;
}
.btn--ghost:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--chrome-text);
}
.btn--ghost:active {
transform: none;
box-shadow: none;
}
.btn__label {
display: inline;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--chrome-muted);
box-shadow: 0 0 0 0 rgba(255, 46, 77, 0);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.btn--ghost[aria-pressed="true"] {
background: rgba(255, 46, 77, 0.16);
border-color: var(--accent);
color: #fff;
}
.btn--ghost[aria-pressed="true"] .dot {
background: var(--accent);
animation: pulse 1.4s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 46, 77, 0.6);
}
70% {
box-shadow: 0 0 0 7px rgba(255, 46, 77, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 46, 77, 0);
}
}
/* ---------- Stage / camera ---------- */
.stage {
position: relative;
overflow: hidden;
background:
var(--halftone),
repeating-linear-gradient(
45deg,
#16161f 0 12px,
#18182230 12px 24px
),
#121218;
background-size: 6px 6px, auto, auto;
outline: none;
}
.stage:focus-visible {
box-shadow: inset 0 0 0 3px var(--accent-blue);
}
.camera {
position: absolute;
inset: 0;
transform-origin: 0 0;
transition: transform 0.62s var(--ease);
will-change: transform;
}
/* The virtual page canvas is large; panels are positioned absolutely on it */
.page {
position: absolute;
background: var(--paper);
border: 3px solid var(--ink);
display: none;
}
.page.is-active {
display: block;
}
.panel {
position: absolute;
overflow: hidden;
border: 3px solid var(--ink);
background: var(--panel);
box-shadow: 6px 6px 0 rgba(14, 14, 18, 0.35);
display: flex;
flex-direction: column;
justify-content: flex-end;
filter: saturate(0.92);
transition: filter 0.4s ease, opacity 0.4s ease;
}
.panel::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--halftone);
background-size: 6px 6px;
opacity: 0.5;
mix-blend-mode: multiply;
pointer-events: none;
}
.panel.is-dim {
filter: saturate(0.55) brightness(0.78);
opacity: 0.82;
}
.panel.is-focus {
filter: saturate(1.05) contrast(1.04);
}
.panel__art {
position: absolute;
inset: 0;
}
.panel__sfx {
position: absolute;
font-family: "Bangers", system-ui, sans-serif;
color: #fff;
text-shadow:
2px 2px 0 var(--ink),
-2px -2px 0 var(--ink),
2px -2px 0 var(--ink),
-2px 2px 0 var(--ink);
letter-spacing: 2px;
transform: rotate(-6deg);
pointer-events: none;
}
/* Speech balloon */
.balloon {
position: absolute;
max-width: 60%;
background: #fff;
color: var(--ink);
border: 2.5px solid var(--ink);
border-radius: 16px;
padding: 8px 12px;
font-weight: 600;
font-size: clamp(0.72rem, 1.4vw, 0.95rem);
line-height: 1.3;
z-index: 2;
}
.balloon::after {
content: "";
position: absolute;
bottom: -14px;
left: 24px;
width: 0;
height: 0;
border: 9px solid transparent;
border-top-color: var(--ink);
}
.balloon::before {
content: "";
position: absolute;
bottom: -9px;
left: 27px;
width: 0;
height: 0;
border: 6px solid transparent;
border-top-color: #fff;
z-index: 1;
}
.balloon--thought {
border-radius: 999px;
}
.panel__no {
position: absolute;
top: 6px;
left: 6px;
font-family: "Bangers", system-ui, sans-serif;
font-size: 0.9rem;
letter-spacing: 1px;
background: var(--accent-2);
color: var(--ink);
border: 2px solid var(--ink);
border-radius: var(--r-sm);
padding: 1px 8px;
z-index: 3;
}
/* corner stamp */
.sfx--corner {
position: absolute;
top: 12px;
right: 14px;
font-family: "Bangers", system-ui, sans-serif;
font-size: 0.95rem;
letter-spacing: 2px;
color: var(--accent-2);
background: rgba(14, 14, 18, 0.55);
border: 2px solid var(--accent-2);
padding: 2px 10px;
border-radius: var(--r-sm);
transform: rotate(3deg);
pointer-events: none;
z-index: 4;
}
/* edge nav hotzones */
.edge {
position: absolute;
top: 0;
bottom: 0;
width: 18%;
max-width: 140px;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
z-index: 3;
opacity: 0;
transition: opacity 0.2s ease, background 0.2s ease;
}
.edge--prev {
left: 0;
justify-items: start;
padding-left: 10px;
}
.edge--next {
right: 0;
justify-items: end;
padding-right: 10px;
}
.stage:hover .edge,
.edge:focus-visible {
opacity: 1;
}
.edge--prev:hover {
background: linear-gradient(to right, rgba(14, 14, 18, 0.45), transparent);
}
.edge--next:hover {
background: linear-gradient(to left, rgba(14, 14, 18, 0.45), transparent);
}
.edge:focus-visible {
outline: 3px solid var(--accent-blue);
outline-offset: -3px;
}
.edge__chev {
font-size: 2.4rem;
font-weight: 800;
text-shadow: 2px 2px 0 var(--ink);
}
/* ---------- Dots ---------- */
.dots {
display: flex;
gap: 8px;
align-items: center;
margin-inline: auto;
}
.pdot {
width: 12px;
height: 12px;
padding: 0;
border-radius: 50%;
border: 2px solid var(--chrome-text);
background: transparent;
cursor: pointer;
transition: transform 0.12s var(--ease), background 0.2s ease;
}
.pdot:hover {
transform: scale(1.2);
background: rgba(255, 255, 255, 0.3);
}
.pdot:focus-visible {
outline: 3px solid var(--accent-blue);
outline-offset: 2px;
}
.pdot[aria-selected="true"] {
background: var(--accent);
border-color: var(--accent);
transform: scale(1.25);
}
.caption {
flex: 1 1 100%;
order: 5;
text-align: center;
font-size: 0.82rem;
color: var(--chrome-muted);
min-height: 1.2em;
letter-spacing: 0.2px;
}
/* ---------- Scrub ---------- */
.scrub {
height: 5px;
background: #000;
}
.scrub__fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width 0.45s var(--ease);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 84px;
transform: translate(-50%, 16px);
background: var(--ink);
color: var(--paper);
border: 2px solid var(--accent-2);
border-radius: var(--r-md);
padding: 10px 18px;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.3px;
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s var(--ease);
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
@media (prefers-reduced-motion: reduce) {
.camera,
.toast,
.scrub__fill {
transition-duration: 0.001ms;
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.bar {
padding: 8px 10px;
gap: 8px;
}
.series__badge {
width: 36px;
height: 36px;
font-size: 1rem;
}
.series__title {
font-size: 1.1rem;
}
.series__issue {
display: none;
}
.pagelabel {
font-size: 0.72rem;
}
.btn {
padding: 8px 11px;
font-size: 0.78rem;
box-shadow: 2px 2px 0 var(--ink);
}
.btn__label {
display: none;
}
.bar--bottom {
justify-content: space-between;
}
.edge {
width: 28%;
}
.edge__chev {
font-size: 1.8rem;
}
.caption {
font-size: 0.74rem;
}
}/* Neon Ronin — Guided View reader
* Virtual page camera: a large fixed-size "page" canvas holds panels positioned
* in canvas coordinates. For each panel we compute a CSS transform that fits the
* panel's rect into the stage viewport (scale + translate), then animate the camera.
*/
(function () {
"use strict";
/* ---- Virtual canvas size (logical units) ---- */
var CANVAS_W = 1200;
var CANVAS_H = 1600;
var PAD = 0.06; // breathing room around a focused panel (fraction)
/* ---- Fictional issue data: 3 pages of panels ----
* Each panel rect is in canvas units: {x,y,w,h} on the CANVAS_W x CANVAS_H page.
*/
var PAGES = [
{
bg: "linear-gradient(160deg,#1b2a4a,#0e0e12)",
panels: [
{
x: 40, y: 40, w: 1120, h: 460,
art: "radial-gradient(120% 120% at 70% 20%,#ff7a3d,#7a1f4d 55%,#1b1030)",
no: "1", sfx: { t: "VRRMMM", x: "62%", y: "16%", s: 64, c: "#ffd23f" },
balloon: { t: "Tokyo never sleeps. Tonight, neither do I.", x: "5%", y: "62%" },
cap: "Page 1 - The Ronin glides over a rain-slick skyline."
},
{
x: 40, y: 540, w: 540, h: 480,
art: "linear-gradient(135deg,#2e6bff,#0b1430)",
no: "2", sfx: { t: "BEEP", x: "8%", y: "12%", s: 40, c: "#ff2e4d" },
balloon: { t: "Signal locked. Cargo bay, sub-level 9.", x: "8%", y: "60%", thought: true },
cap: "Page 1 - A wrist console pings with a stolen ping."
},
{
x: 620, y: 540, w: 540, h: 480,
art: "radial-gradient(100% 100% at 30% 30%,#39ffb0,#0f3a2c 60%,#06140f)",
no: "3", sfx: { t: "TSK", x: "60%", y: "20%", s: 44, c: "#ffd23f" },
balloon: { t: "They think the firewall stops me.", x: "6%", y: "58%" },
cap: "Page 1 - Code cascades across her visor."
},
{
x: 40, y: 1060, w: 1120, h: 500,
art: "linear-gradient(110deg,#ff2e4d,#7a0f24 60%,#1a060d)",
no: "4", sfx: { t: "KRAKK!", x: "40%", y: "20%", s: 88, c: "#fff" },
balloon: { t: "Cute. Now it doesn't.", x: "55%", y: "62%" },
cap: "Page 1 - The barrier shatters in a spray of neon glass."
}
]
},
{
bg: "linear-gradient(160deg,#2a1b4a,#0e0e12)",
panels: [
{
x: 40, y: 40, w: 760, h: 520,
art: "radial-gradient(120% 120% at 20% 30%,#b14dff,#3a1170 55%,#120824)",
no: "1", sfx: { t: "HUMMM", x: "55%", y: "14%", s: 56, c: "#39ffb0" },
balloon: { t: "Iron Vanguard. I should have known.", x: "6%", y: "60%" },
cap: "Page 2 - A hulking sentinel powers up in the dark."
},
{
x: 840, y: 40, w: 320, h: 520,
art: "linear-gradient(180deg,#ffd23f,#b8870b 70%,#2a1d04)",
no: "2", sfx: { t: "CLNK", x: "20%", y: "12%", s: 38, c: "#0e0e12" },
balloon: { t: "Surrender the drive.", x: "8%", y: "10%" },
cap: "Page 2 - A gauntlet clenches around a data spike."
},
{
x: 40, y: 600, w: 1120, h: 440,
art: "radial-gradient(120% 140% at 50% 0%,#2e6bff,#0a1638 55%,#04081a)",
no: "3", sfx: { t: "WHOOSH", x: "30%", y: "16%", s: 70, c: "#ffd23f" },
balloon: { t: "Make me.", x: "62%", y: "58%" },
cap: "Page 2 - She vaults the gap, blade trailing light."
},
{
x: 40, y: 1080, w: 360, h: 480,
art: "linear-gradient(135deg,#39ffb0,#063a2b)",
no: "4", sfx: null,
balloon: { t: "Three... two...", x: "10%", y: "14%", thought: true },
cap: "Page 2 - A countdown blooms on the floor."
},
{
x: 440, y: 1080, w: 720, h: 480,
art: "radial-gradient(120% 120% at 70% 70%,#ff2e4d,#5a0d1c 60%,#16050a)",
no: "5", sfx: { t: "BWOOM!", x: "30%", y: "18%", s: 92, c: "#ffd23f" },
balloon: { t: "...one.", x: "8%", y: "62%" },
cap: "Page 2 - The chamber erupts in a shockwave."
}
]
},
{
bg: "linear-gradient(160deg,#0e3a3a,#0e0e12)",
panels: [
{
x: 40, y: 40, w: 1120, h: 620,
art: "radial-gradient(120% 120% at 40% 20%,#39ffb0,#0e4a40 50%,#04140f)",
no: "1", sfx: { t: "SHHH", x: "60%", y: "14%", s: 60, c: "#fff" },
balloon: { t: "Dust settles. So does the score.", x: "6%", y: "70%" },
cap: "Page 3 - Smoke clears over a ruined data vault."
},
{
x: 40, y: 700, w: 540, h: 420,
art: "linear-gradient(135deg,#ffd23f,#a86f06)",
no: "2", sfx: { t: "TAP", x: "60%", y: "16%", s: 40, c: "#0e0e12" },
balloon: { t: "Drive's mine. Always was.", x: "6%", y: "60%" },
cap: "Page 3 - The data spike clicks into her glove."
},
{
x: 620, y: 700, w: 540, h: 420,
art: "radial-gradient(120% 120% at 30% 70%,#b14dff,#2a0f55 60%,#0c0420)",
no: "3", sfx: null,
balloon: { t: "We will meet again, Ronin.", x: "8%", y: "12%" },
cap: "Page 3 - The Vanguard sinks back into shadow."
},
{
x: 40, y: 1160, w: 1120, h: 400,
art: "linear-gradient(110deg,#ff2e4d,#2e6bff)",
no: "4", sfx: { t: "TO BE CONTINUED", x: "26%", y: "40%", s: 58, c: "#fff" },
balloon: { t: "Count on it.", x: "8%", y: "58%" },
cap: "Page 3 - She vanishes into the neon rain. (End of issue)"
}
]
}
];
/* ---- Build a flat panel index across all pages ---- */
var FLAT = [];
PAGES.forEach(function (pg, pi) {
pg.panels.forEach(function (p, idx) {
FLAT.push({ page: pi, idx: idx });
});
});
/* ---- DOM ---- */
var stage = document.getElementById("stage");
var camera = document.getElementById("camera");
var dotsWrap = document.getElementById("dots");
var captionEl = document.getElementById("caption");
var pageNumEl = document.getElementById("pageNum");
var pageTotalEl = document.getElementById("pageTotal");
var scrubFill = document.getElementById("scrubFill");
var toastEl = document.getElementById("toast");
var prevBtn = document.getElementById("prevBtn");
var nextBtn = document.getElementById("nextBtn");
var prevZone = document.getElementById("prevZone");
var nextZone = document.getElementById("nextZone");
var autoplayBtn = document.getElementById("autoplayBtn");
var resetBtn = document.getElementById("resetBtn");
var current = 0; // index into FLAT
var autoplay = false;
var autoTimer = null;
var AUTO_MS = 3200;
/* ---- Render the virtual canvas ---- */
function buildCanvas() {
camera.innerHTML = "";
pageTotalEl.textContent = String(PAGES.length);
PAGES.forEach(function (pg, pi) {
var pageEl = document.createElement("div");
pageEl.className = "page";
pageEl.dataset.page = String(pi);
pageEl.style.width = CANVAS_W + "px";
pageEl.style.height = CANVAS_H + "px";
pageEl.style.left = "0px";
pageEl.style.top = "0px";
pageEl.style.background = pg.bg;
pg.panels.forEach(function (p, idx) {
var panel = document.createElement("div");
panel.className = "panel";
panel.dataset.page = String(pi);
panel.dataset.idx = String(idx);
panel.style.left = p.x + "px";
panel.style.top = p.y + "px";
panel.style.width = p.w + "px";
panel.style.height = p.h + "px";
var art = document.createElement("div");
art.className = "panel__art";
art.style.background = p.art;
panel.appendChild(art);
var no = document.createElement("span");
no.className = "panel__no";
no.textContent = p.no;
panel.appendChild(no);
if (p.sfx) {
var sfx = document.createElement("span");
sfx.className = "panel__sfx";
sfx.textContent = p.sfx.t;
sfx.style.left = p.sfx.x;
sfx.style.top = p.sfx.y;
sfx.style.fontSize = p.sfx.s + "px";
if (p.sfx.c) sfx.style.color = p.sfx.c;
panel.appendChild(sfx);
}
if (p.balloon) {
var b = document.createElement("div");
b.className = "balloon" + (p.balloon.thought ? " balloon--thought" : "");
b.textContent = p.balloon.t;
b.style.left = p.balloon.x;
b.style.top = p.balloon.y;
panel.appendChild(b);
}
pageEl.appendChild(panel);
});
camera.appendChild(pageEl);
});
}
/* ---- Build progress dots for a given page ---- */
function buildDots(pageIdx) {
dotsWrap.innerHTML = "";
var count = PAGES[pageIdx].panels.length;
for (var i = 0; i < count; i++) {
var d = document.createElement("button");
d.className = "pdot";
d.type = "button";
d.setAttribute("role", "tab");
d.setAttribute("aria-label", "Panel " + (i + 1) + " of " + count);
(function (panelIdx) {
d.addEventListener("click", function () {
var flat = flatIndexOf(pageIdx, panelIdx);
goTo(flat, true);
});
})(i);
dotsWrap.appendChild(d);
}
}
function flatIndexOf(page, idx) {
for (var i = 0; i < FLAT.length; i++) {
if (FLAT[i].page === page && FLAT[i].idx === idx) return i;
}
return 0;
}
/* ---- Compute & apply camera transform to fit a panel into the stage ---- */
function focusPanel(page, idx) {
var p = PAGES[page].panels[idx];
var vw = stage.clientWidth;
var vh = stage.clientHeight;
if (!vw || !vh) return;
// padded panel rect in canvas units
var padX = p.w * PAD;
var padY = p.h * PAD;
var rx = p.x - padX;
var ry = p.y - padY;
var rw = p.w + padX * 2;
var rh = p.h + padY * 2;
// scale so the padded rect fits inside the viewport (contain)
var scale = Math.min(vw / rw, vh / rh);
// center the rect in the viewport
var tx = (vw - rw * scale) / 2 - rx * scale;
var ty = (vh - rh * scale) / 2 - ry * scale;
camera.style.transform =
"translate(" + tx + "px," + ty + "px) scale(" + scale + ")";
}
/* ---- Activate page + highlight panel ---- */
function render(animate) {
var info = FLAT[current];
var page = info.page;
var idx = info.idx;
// show only the active page
var pages = camera.querySelectorAll(".page");
pages.forEach(function (pe) {
pe.classList.toggle("is-active", Number(pe.dataset.page) === page);
});
// (re)build dots if page changed
if (dotsWrap.dataset.page !== String(page)) {
buildDots(page);
dotsWrap.dataset.page = String(page);
}
// dot selection
var dots = dotsWrap.querySelectorAll(".pdot");
dots.forEach(function (d, i) {
d.setAttribute("aria-selected", i === idx ? "true" : "false");
});
// panel focus/dim states
var panels = camera.querySelectorAll(".panel");
panels.forEach(function (pe) {
var samePage = Number(pe.dataset.page) === page;
var isFocus = samePage && Number(pe.dataset.idx) === idx;
pe.classList.toggle("is-focus", isFocus);
pe.classList.toggle("is-dim", samePage && !isFocus);
});
pageNumEl.textContent = String(page + 1);
captionEl.textContent = PAGES[page].panels[idx].cap;
// scrub across whole issue
var pct = FLAT.length > 1 ? (current / (FLAT.length - 1)) * 100 : 0;
scrubFill.style.width = pct.toFixed(1) + "%";
// camera movement
var prevTransition = camera.style.transition;
if (!animate) camera.style.transition = "none";
focusPanel(page, idx);
if (!animate) {
// force reflow then restore
void camera.offsetWidth;
camera.style.transition = prevTransition || "";
}
// edge button availability hints
prevBtn.disabled = current === 0;
nextBtn.disabled = current === FLAT.length - 1;
prevZone.disabled = current === 0;
nextZone.disabled = current === FLAT.length - 1;
}
/* ---- Navigation ---- */
function goTo(flat, fromUser) {
var max = FLAT.length - 1;
flat = Math.max(0, Math.min(max, flat));
var prevPage = FLAT[current].page;
current = flat;
render(true);
if (fromUser && FLAT[current].page !== prevPage) {
toast("Page " + (FLAT[current].page + 1));
}
}
function next(fromUser) {
if (current >= FLAT.length - 1) {
if (autoplay) setAutoplay(false);
toast("End of issue");
return;
}
goTo(current + 1, fromUser);
}
function prev(fromUser) {
if (current <= 0) {
toast("Start of issue");
return;
}
goTo(current - 1, fromUser);
}
/* ---- Autoplay ---- */
function setAutoplay(on) {
autoplay = on;
autoplayBtn.setAttribute("aria-pressed", on ? "true" : "false");
autoplayBtn.querySelector(".btn__label").textContent = on ? "Playing" : "Autoplay";
clearInterval(autoTimer);
if (on) {
toast("Autoplay on");
autoTimer = setInterval(function () {
if (current >= FLAT.length - 1) {
setAutoplay(false);
toast("End of issue");
} else {
goTo(current + 1, false);
}
}, AUTO_MS);
} else {
toast("Autoplay off");
}
}
/* ---- Toast helper ---- */
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 1500);
}
/* ---- Events ---- */
nextBtn.addEventListener("click", function () { next(true); });
prevBtn.addEventListener("click", function () { prev(true); });
nextZone.addEventListener("click", function () { next(true); });
prevZone.addEventListener("click", function () { prev(true); });
resetBtn.addEventListener("click", function () {
setAutoplay(false);
goTo(0, false);
toast("Restarted");
});
autoplayBtn.addEventListener("click", function () {
setAutoplay(!autoplay);
});
stage.addEventListener("keydown", onKey);
document.addEventListener("keydown", function (e) {
// global arrows when focus isn't in a text field
var tag = (e.target && e.target.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.target === stage) return; // handled by stage listener
onKey(e);
});
function onKey(e) {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
case " ":
case "Enter":
e.preventDefault();
next(true);
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
prev(true);
break;
case "Home":
e.preventDefault();
goTo(0, true);
break;
case "End":
e.preventDefault();
goTo(FLAT.length - 1, true);
break;
case "p":
case "P":
setAutoplay(!autoplay);
break;
}
}
// recompute transform on resize (no animation jump)
var resizeTimer = null;
window.addEventListener("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () { render(false); }, 80);
});
/* ---- Boot ---- */
buildCanvas();
// wait a frame so layout is measured before first focus
requestAnimationFrame(function () {
render(false);
requestAnimationFrame(function () { render(false); });
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neon Ronin — Guided View</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Bangers&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="reader" role="application" aria-label="Guided view comic reader">
<!-- Top chrome -->
<header class="bar bar--top">
<div class="series">
<span class="series__badge" aria-hidden="true">NR</span>
<span class="series__meta">
<strong class="series__title">Neon Ronin</strong>
<span class="series__issue">Issue #14 — “Static Bloom”</span>
</span>
</div>
<div class="pagelabel" aria-live="polite">
<span class="pagelabel__txt">Page</span>
<strong id="pageNum">1</strong>
<span class="pagelabel__sep">/</span>
<span id="pageTotal">3</span>
</div>
<div class="bar__actions">
<button id="autoplayBtn" class="btn btn--ghost" type="button" aria-pressed="false">
<span class="dot" aria-hidden="true"></span>
<span class="btn__label">Autoplay</span>
</button>
<button id="resetBtn" class="btn btn--ghost" type="button" title="Restart issue">
Restart
</button>
</div>
</header>
<!-- Stage: virtual page camera -->
<main class="stage" id="stage" tabindex="0" aria-label="Comic stage. Use arrow keys to move panel by panel.">
<div class="camera" id="camera">
<!-- pages are injected here -->
</div>
<div class="sfx sfx--corner" aria-hidden="true">GUIDED VIEW</div>
<!-- edge nav hotzones -->
<button class="edge edge--prev" id="prevZone" type="button" aria-label="Previous panel">
<span class="edge__chev">‹</span>
</button>
<button class="edge edge--next" id="nextZone" type="button" aria-label="Next panel">
<span class="edge__chev">›</span>
</button>
</main>
<!-- Bottom chrome -->
<footer class="bar bar--bottom">
<button id="prevBtn" class="btn btn--ink" type="button">
<span aria-hidden="true">←</span> Prev
</button>
<div class="dots" id="dots" role="tablist" aria-label="Panels on this page"></div>
<div class="caption" id="caption" aria-live="polite"></div>
<button id="nextBtn" class="btn btn--accent" type="button">
Next <span aria-hidden="true">→</span>
</button>
</footer>
<!-- scrub progress for whole issue -->
<div class="scrub" aria-hidden="true">
<div class="scrub__fill" id="scrubFill"></div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Guided View (panel-by-panel transitions)
A full-bleed guided-view reader for the fictional series Neon Ronin, built the way digital comics present a story on phones: one panel fills the screen at a time, and advancing pans and zooms a virtual page camera from one panel rectangle to the next. The panels live on a large logical page canvas with thick ink borders, gutters, halftone Ben-Day texture, hand-lettered SFX, and speech balloons with tails — and a single CSS transform on that canvas does all the moving.
The chrome stays minimal and out of the way: a series badge and issue title up top, a live page label and an issue-wide scrub bar, and a bottom bar with prev/next, a row of progress dots for the panels on the current page, and a one-line caption. The focused panel reads at full contrast while the rest of the page dims, so the eye always knows where it is.
JavaScript owns the camera. For each panel it pads the panel rect, computes the scale that contains it within the stage, centers it, and animates the transform with an eased transition; resizing recomputes the fit without a jump. Navigation comes from arrow keys (plus space, Home/End and a P autoplay shortcut), left/right edge hotzones, tappable dots, and an autoplay toggle that walks the issue every few seconds and stops at the end — each meaningful change announced through a small toast.
Illustrative UI only — fictional series, characters, and data.