News — Photo Essay / Visual Story
A full-bleed editorial photo essay from a fictional coastal paper, opening on a serif title spread and moving through twelve scroll-snapping plates of duotone press photography, each with an italic caption and credit line, interleaved with two-column text passages and an oversized pull quote. A sticky masthead frame counter tracks 01 of 12 with a thin red progress rule, keyboard and button navigation jump between plates, captions fade in on view, and clicking any frame opens a focus-trapped lightbox zoom — all in vanilla JS.
MCP
Code
:root {
--cream: #f4efe4;
--paper: #faf7f0;
--white: #ffffff;
--newsprint: #efe9da;
--ink: #16130f;
--ink-2: #2b2620;
--ink-3: #4a443b;
--muted: #7a7164;
--red: #b4291f;
--red-d: #8f1f17;
--red-50: #f3dcd9;
--rule: rgba(22, 19, 15, 0.16);
--rule-2: rgba(22, 19, 15, 0.30);
--rule-hair: rgba(22, 19, 15, 0.10);
--ok: #2f7d4f;
--warn: #b67a18;
--danger: #b4291f;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 12px;
--serif: "Playfair Display", "Times New Roman", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--hud-h: 56px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font-family: var(--sans);
background: var(--cream);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.skip-link {
position: fixed;
left: 12px;
top: -60px;
z-index: 200;
background: var(--ink);
color: var(--paper);
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
border-radius: var(--r-sm);
transition: top .18s ease;
}
.skip-link:focus { top: 12px; }
kbd {
font-family: var(--sans);
font-size: 11px;
font-weight: 600;
background: var(--white);
border: 1px solid var(--rule);
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: 1px 6px;
color: var(--ink-2);
}
/* ============ FRAME HUD ============ */
.frame-hud {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--hud-h);
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 18px;
background: color-mix(in srgb, var(--paper) 86%, transparent);
backdrop-filter: blur(10px) saturate(1.1);
-webkit-backdrop-filter: blur(10px) saturate(1.1);
border-bottom: 1px solid var(--rule);
}
.frame-hud__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.frame-hud__mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1.5px solid var(--ink);
border-radius: var(--r-sm);
font-family: var(--serif);
font-weight: 800;
font-size: 14px;
letter-spacing: .02em;
color: var(--ink);
}
.frame-hud__paper {
font-family: var(--serif);
font-weight: 700;
font-size: 15px;
letter-spacing: .01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.frame-hud__counter {
font-family: var(--serif);
font-weight: 700;
display: flex;
align-items: baseline;
gap: 4px;
letter-spacing: .04em;
}
.frame-hud__cur { font-size: 20px; color: var(--red); }
.frame-hud__sep { font-size: 16px; color: var(--rule-2); }
.frame-hud__total { font-size: 15px; color: var(--muted); }
.frame-hud__controls { display: flex; gap: 8px; }
.hud-btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
font-size: 15px;
background: var(--white);
color: var(--ink-2);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease, transform .08s ease;
}
.hud-btn:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.hud-btn:active { transform: translateY(1px); }
.hud-btn:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.frame-progress {
position: fixed;
top: var(--hud-h);
left: 0;
right: 0;
height: 3px;
z-index: 99;
background: var(--rule-hair);
}
.frame-progress__bar {
display: block;
height: 100%;
width: 0%;
background: var(--red);
transition: width .3s cubic-bezier(.4,0,.2,1);
}
/* ============ DECK / PLATES ============ */
.deck {
scroll-snap-type: y proximity;
overflow-y: auto;
height: 100vh;
}
.plate {
min-height: 100vh;
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--hud-h) + 28px) 24px 40px;
position: relative;
}
/* ============ DUOTONE "PHOTOS" ============ */
.duo {
position: relative;
overflow: hidden;
background-color: var(--ink-2);
isolation: isolate;
}
.duo .grain {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
opacity: .5;
mix-blend-mode: overlay;
background-image:
radial-gradient(rgba(255,255,255,.10) 1px, transparent 1.4px),
radial-gradient(rgba(0,0,0,.16) 1px, transparent 1.4px);
background-size: 3px 3px, 4px 4px;
background-position: 0 0, 1px 2px;
}
/* dawn — title plate: cool indigo to ember red */
.duo--dawn {
background:
radial-gradient(120% 90% at 75% 12%, rgba(244,200,150,.42), transparent 55%),
radial-gradient(140% 120% at 20% 110%, rgba(180,41,31,.55), transparent 60%),
linear-gradient(165deg, #1b2233 0%, #2c2436 38%, #5a2a28 70%, #8f1f17 100%);
}
/* harbor — silvered blue dawn water */
.duo--harbor {
background:
radial-gradient(90% 70% at 50% 20%, rgba(238,231,210,.55), transparent 60%),
linear-gradient(180deg, #c8c9c2 0%, #8f9aa0 30%, #4f5e69 60%, #232b33 100%),
repeating-linear-gradient(180deg, rgba(255,255,255,.05) 0 2px, transparent 2px 9px);
background-blend-mode: screen, normal, overlay;
}
/* hull — weathered green hull + dark waterline */
.duo--hull {
background:
linear-gradient(180deg, transparent 0 52%, rgba(15,18,14,.66) 52% 56%, transparent 56%),
radial-gradient(80% 60% at 30% 35%, rgba(120,150,120,.4), transparent 60%),
linear-gradient(160deg, #46503f 0%, #2f3a30 45%, #1a2018 100%);
}
/* hands — warm sepia close-up */
.duo--hands {
background:
radial-gradient(70% 60% at 40% 45%, rgba(245,222,182,.6), transparent 65%),
linear-gradient(150deg, #6e4a30 0%, #4a3220 50%, #241712 100%);
}
/* marsh — bleached mud and grey-green cordgrass */
.duo--marsh {
background:
radial-gradient(120% 80% at 60% 30%, rgba(232,226,205,.5), transparent 60%),
linear-gradient(175deg, #9aa089 0%, #6f7860 38%, #494f3c 70%, #2a2e22 100%),
repeating-linear-gradient(92deg, rgba(20,30,18,.10) 0 3px, transparent 3px 14px);
}
/* lantern — deep night with a single warm glow */
.duo--lantern {
background:
radial-gradient(28% 32% at 64% 42%, rgba(255,206,120,.85), rgba(200,120,40,.3) 40%, transparent 70%),
linear-gradient(180deg, #0d1422 0%, #111a26 55%, #060a12 100%);
}
/* reef — dark wet shell ridge under steel sky */
.duo--reef {
background:
linear-gradient(180deg, #aeb4ad 0%, #7c857f 34%, transparent 50%),
linear-gradient(180deg, transparent 48%, #2b2a26 56%, #14130f 100%),
radial-gradient(60% 30% at 50% 60%, rgba(70,60,50,.6), transparent 70%);
}
/* portrait — soft window-light duotone in ink + red */
.duo--portrait {
background:
radial-gradient(55% 70% at 32% 38%, rgba(244,231,205,.62), transparent 62%),
linear-gradient(155deg, #4a443b 0%, #2b2620 45%, #161310 100%),
radial-gradient(40% 50% at 80% 80%, rgba(180,41,31,.22), transparent 70%);
}
/* dusk — coda: ember sky over dark water */
.duo--dusk {
background:
linear-gradient(180deg, #e8a25a 0%, #c0512c 22%, #6e2a28 44%, #2a2530 64%, #14131a 100%),
radial-gradient(60% 22% at 50% 46%, rgba(255,210,150,.4), transparent 70%);
}
/* ============ TITLE SPREAD ============ */
.plate--title { align-items: stretch; }
.title-spread {
width: min(1180px, 100%);
display: grid;
grid-template-columns: 1.15fr 1fr;
grid-template-rows: auto 1fr;
gap: 0 48px;
align-self: center;
}
.masthead { grid-column: 1 / -1; }
.masthead__rule {
height: 0;
margin: 14px 0 0;
border-top: 3px double var(--rule-2);
}
.kicker {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: .22em;
text-transform: uppercase;
color: var(--red);
}
.title-spread__body {
padding: 34px 0 0;
align-self: center;
max-width: 36ch;
}
.title-spread__overline {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--muted);
}
.title-spread__head {
margin: 0;
font-family: var(--serif);
font-weight: 900;
font-size: clamp(3.4rem, 9vw, 6.6rem);
line-height: .92;
letter-spacing: -.02em;
color: var(--ink);
}
.title-spread__dek {
margin: 22px 0 0;
font-size: 1.06rem;
line-height: 1.55;
color: var(--ink-3);
max-width: 34ch;
}
.byline {
margin: 24px 0 0;
padding-top: 16px;
border-top: 1px solid var(--rule);
font-size: 13px;
color: var(--ink-2);
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
}
.byline__name { font-weight: 600; }
.byline__dot { color: var(--rule-2); }
.byline__date, .byline__read { color: var(--muted); }
.byline__read { font-variant: small-caps; letter-spacing: .03em; }
.title-spread__hint {
margin: 28px 0 0;
font-size: 12.5px;
color: var(--muted);
line-height: 1.7;
}
.title-spread__hint kbd { margin: 0 1px; }
.title-spread__hint em { color: var(--red); font-style: italic; }
.title-spread__hint-key { font-weight: 600; color: var(--ink-3); }
.title-spread__plate {
margin: 34px 0 0;
border-radius: var(--r-sm);
min-height: 60vh;
border: 1px solid var(--rule);
box-shadow: 0 1px 0 var(--rule-hair);
}
/* ============ FRAME (full-bleed-ish plate photo) ============ */
.frame {
margin: 0;
width: min(1100px, 100%);
display: flex;
flex-direction: column;
}
.frame__img {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
padding: 0;
cursor: zoom-in;
border-radius: var(--r-sm);
box-shadow: 0 1px 0 var(--rule-hair), 0 18px 40px -28px rgba(22,19,15,.5);
transition: transform .35s cubic-bezier(.2,.7,.3,1), box-shadow .35s ease;
}
.frame--split .frame__img { aspect-ratio: 4 / 5; max-width: 680px; margin: 0 auto; }
.frame--coda .frame__img { aspect-ratio: 21 / 9; }
.frame__img:hover { box-shadow: 0 1px 0 var(--rule-hair), 0 26px 54px -26px rgba(22,19,15,.6); }
.frame__img:focus-visible { outline: 3px solid var(--red); outline-offset: 4px; }
.frame__num {
position: absolute;
left: 14px;
bottom: 12px;
z-index: 4;
font-family: var(--serif);
font-weight: 800;
font-size: 13px;
letter-spacing: .08em;
color: var(--paper);
background: rgba(22,19,15,.5);
padding: 3px 9px;
border-radius: var(--r-sm);
backdrop-filter: blur(2px);
}
/* ============ CAPTIONS ============ */
.caption {
margin: 14px 2px 0;
padding-left: 14px;
border-left: 2px solid var(--red);
max-width: 62ch;
opacity: 0;
transform: translateY(8px);
transition: opacity .6s ease, transform .6s ease;
}
.plate.is-active .caption { opacity: 1; transform: none; }
.caption__text {
display: block;
font-family: var(--serif);
font-style: italic;
font-size: 1.02rem;
line-height: 1.5;
color: var(--ink-2);
}
.caption__text em { font-style: normal; }
.caption__credit {
display: block;
margin-top: 7px;
font-size: 11px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
}
.caption--coda { max-width: 70ch; }
/* ============ TEXT PASSAGES ============ */
.plate--text { padding-left: 24px; padding-right: 24px; }
.passage {
width: min(680px, 100%);
columns: 2;
column-gap: 40px;
column-rule: 1px solid var(--rule-hair);
}
.passage__lead, .passage__body {
margin: 0 0 1.1em;
font-size: 1.04rem;
line-height: 1.62;
color: var(--ink-2);
text-align: justify;
hyphens: auto;
break-inside: avoid-column;
}
.passage__lead { break-after: column; }
.passage__lead::first-letter {
float: left;
font-family: var(--serif);
font-weight: 800;
font-size: 3.6em;
line-height: .76;
padding: 6px 10px 0 0;
color: var(--red);
}
/* ============ PULL QUOTE ============ */
.plate--quote { background: var(--paper); }
.pullquote {
margin: 0;
width: min(820px, 100%);
text-align: center;
padding: 0 12px;
}
.pullquote__q {
margin: 0;
font-family: var(--serif);
font-weight: 600;
font-style: italic;
font-size: clamp(1.9rem, 5.5vw, 3.4rem);
line-height: 1.12;
letter-spacing: -.01em;
color: var(--ink);
position: relative;
}
.pullquote__q::before,
.pullquote__q::after {
content: "";
display: block;
width: 64px;
height: 0;
margin: 0 auto;
border-top: 2px solid var(--red);
}
.pullquote__q::before { margin-bottom: 28px; }
.pullquote__q::after { margin-top: 28px; }
.pullquote__by {
margin: 18px 0 0;
font-size: 12px;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
/* ============ CODA ============ */
.frame--coda { width: min(1180px, 100%); }
.coda__end {
margin-top: 30px;
padding-top: 22px;
border-top: 3px double var(--rule-2);
text-align: center;
}
.coda__paper {
margin: 0;
font-family: var(--serif);
font-weight: 800;
font-size: 1.5rem;
letter-spacing: .01em;
}
.coda__note {
margin: 6px 0 18px;
font-size: 11px;
font-weight: 600;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.coda__top {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--rule);
padding: 9px 18px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s ease, color .15s ease, border-color .15s ease;
}
.coda__top:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.coda__top:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
/* ============ LIGHTBOX ============ */
.lightbox {
position: fixed;
inset: 0;
z-index: 300;
display: grid;
place-items: center;
padding: 28px;
background: rgba(12,10,8,.86);
backdrop-filter: blur(6px);
animation: lbIn .22s ease;
}
.lightbox[hidden] { display: none; }
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lightbox__close {
position: absolute;
top: 18px;
right: 22px;
width: 44px;
height: 44px;
font-size: 26px;
line-height: 1;
color: var(--paper);
background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.22);
border-radius: 50%;
cursor: pointer;
transition: background .15s ease, transform .08s ease;
}
.lightbox__close:hover { background: rgba(255,255,255,.18); }
.lightbox__close:active { transform: scale(.94); }
.lightbox__close:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.lightbox__fig {
margin: 0;
width: min(1080px, 100%);
max-height: 100%;
display: flex;
flex-direction: column;
}
.lightbox__img {
width: 100%;
aspect-ratio: 16 / 9;
max-height: 74vh;
border-radius: var(--r-sm);
box-shadow: 0 30px 80px -30px rgba(0,0,0,.8);
}
.lightbox__cap {
margin: 14px 2px 0;
padding-left: 14px;
border-left: 2px solid var(--red);
font-family: var(--serif);
font-style: italic;
font-size: 1rem;
color: var(--newsprint);
max-width: 62ch;
}
.lightbox__cap b {
display: block;
margin-top: 7px;
font-family: var(--sans);
font-style: normal;
font-weight: 600;
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(244,239,228,.65);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
z-index: 400;
background: var(--ink);
color: var(--paper);
font-size: 13px;
font-weight: 500;
padding: 10px 18px;
border-radius: var(--r-md);
box-shadow: 0 12px 30px -12px rgba(0,0,0,.5);
opacity: 0;
pointer-events: none;
transition: opacity .25s ease, transform .25s ease;
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
/* ============ RESPONSIVE ============ */
@media (max-width: 920px) {
.title-spread {
grid-template-columns: 1fr;
gap: 0;
}
.title-spread__plate { min-height: 42vh; order: 3; }
.title-spread__body { order: 2; max-width: none; }
}
@media (max-width: 720px) {
.plate {
padding: calc(var(--hud-h) + 22px) 18px 34px;
min-height: 92vh;
}
.passage { columns: 1; column-rule: none; }
.passage__lead::first-letter { font-size: 3.1em; }
.frame--split .frame__img { aspect-ratio: 4 / 5; }
.frame--coda .frame__img { aspect-ratio: 16 / 10; }
.frame-hud__paper { font-size: 13px; }
}
@media (max-width: 480px) {
.frame-hud { padding: 0 12px; gap: 10px; }
.frame-hud__paper { display: none; }
.frame__img { aspect-ratio: 4 / 3; }
.frame--coda .frame__img { aspect-ratio: 4 / 3; }
.caption__text { font-size: .96rem; }
.title-spread__dek { font-size: 1rem; }
.lightbox { padding: 16px; }
}
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
.caption { transition: none; opacity: 1; transform: none; }
.frame__img { transition: none; }
}(function () {
"use strict";
var deck = document.getElementById("plates");
var plates = Array.prototype.slice.call(document.querySelectorAll(".plate"));
var curEl = document.getElementById("frameCur");
var totalEl = document.getElementById("frameTotal");
var progressBar = document.getElementById("progressBar");
var prevBtn = document.getElementById("prevBtn");
var nextBtn = document.getElementById("nextBtn");
var toTop = document.getElementById("toTop");
var toastEl = document.getElementById("toast");
var total = plates.length;
var current = 0; // index into plates
var toastTimer = null;
totalEl.textContent = pad(total);
/* ---------- helpers ---------- */
function pad(n) { return String(n).length < 2 ? "0" + n : String(n); }
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-on");
}, 1900);
}
function setCurrent(idx) {
if (idx < 0) idx = 0;
if (idx > total - 1) idx = total - 1;
current = idx;
var frame = parseInt(plates[idx].getAttribute("data-frame"), 10) || idx + 1;
curEl.textContent = pad(frame);
var pct = total > 1 ? (frame - 1) / (total - 1) * 100 : 0;
progressBar.style.width = pct.toFixed(2) + "%";
plates.forEach(function (p, i) {
p.classList.toggle("is-active", i === idx);
});
}
function goTo(idx, announce) {
if (idx < 0) idx = 0;
if (idx > total - 1) idx = total - 1;
plates[idx].scrollIntoView({ behavior: "smooth", block: "start" });
if (announce) {
var frame = parseInt(plates[idx].getAttribute("data-frame"), 10) || idx + 1;
toast("Plate " + frame + " of " + total);
}
}
function next() { goTo(current + 1, true); }
function prev() { goTo(current - 1, true); }
/* ---------- active-plate tracking via IntersectionObserver ---------- */
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(function (entries) {
var best = null;
entries.forEach(function (e) {
if (e.isIntersecting && (!best || e.intersectionRatio > best.intersectionRatio)) {
best = e;
}
});
if (best) {
var idx = plates.indexOf(best.target);
if (idx !== -1 && idx !== current) setCurrent(idx);
}
}, { root: deck, threshold: [0.35, 0.55, 0.75] });
plates.forEach(function (p) { io.observe(p); });
} else {
// fallback: scroll math
deck.addEventListener("scroll", function () {
var mid = deck.scrollTop + deck.clientHeight / 2;
var idx = 0;
for (var i = 0; i < plates.length; i++) {
if (plates[i].offsetTop <= mid) idx = i;
}
if (idx !== current) setCurrent(idx);
}, { passive: true });
}
/* ---------- controls ---------- */
prevBtn.addEventListener("click", prev);
nextBtn.addEventListener("click", next);
if (toTop) {
toTop.addEventListener("click", function () {
goTo(0, false);
toast("Back to the title spread");
});
}
/* ---------- keyboard navigation ---------- */
document.addEventListener("keydown", function (e) {
if (lightboxOpen) {
if (e.key === "Escape") { closeLightbox(); }
return;
}
var tag = (e.target && e.target.tagName) || "";
if (tag === "INPUT" || tag === "TEXTAREA") return;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
case "PageDown":
e.preventDefault(); next(); break;
case "ArrowLeft":
case "ArrowUp":
case "PageUp":
e.preventDefault(); prev(); break;
case "Home":
e.preventDefault(); goTo(0, true); break;
case "End":
e.preventDefault(); goTo(total - 1, true); break;
case " ":
e.preventDefault(); e.shiftKey ? prev() : next(); break;
default: break;
}
});
/* ---------- lightbox / zoom ---------- */
var lightbox = document.getElementById("lightbox");
var lbImg = document.getElementById("lbImg");
var lbCap = document.getElementById("lbCap");
var lbClose = document.getElementById("lbClose");
var lightboxOpen = false;
var lastFocused = null;
// duotone class carried over to the lightbox image
var DUO_RE = /\bduo--[a-z]+\b/;
function openLightbox(btn) {
var duoCls = (btn.className.match(DUO_RE) || [""])[0];
lbImg.className = "lightbox__img duo " + duoCls;
var fig = btn.closest("figure");
var cap = fig ? fig.querySelector(".caption") : null;
if (cap) {
var text = cap.querySelector(".caption__text");
var credit = cap.querySelector(".caption__credit");
lbCap.innerHTML =
(text ? text.innerHTML : "") +
(credit ? "<b>" + credit.textContent + "</b>" : "");
} else {
lbCap.innerHTML = "";
}
lastFocused = btn;
lightbox.hidden = false;
lightboxOpen = true;
document.body.style.overflow = "hidden";
lbClose.focus();
toast("Zoomed — press Esc to close");
}
function closeLightbox() {
lightbox.hidden = true;
lightboxOpen = false;
document.body.style.overflow = "";
if (lastFocused && typeof lastFocused.focus === "function") lastFocused.focus();
}
document.querySelectorAll("[data-zoom]").forEach(function (btn) {
btn.addEventListener("click", function () { openLightbox(btn); });
});
lbClose.addEventListener("click", closeLightbox);
lightbox.addEventListener("click", function (e) {
if (e.target === lightbox) closeLightbox();
});
/* ---------- init ---------- */
setCurrent(0);
// de-emphasise the scroll hint once the reader has moved on
var hint = document.getElementById("scrollHint");
if (hint) {
var hintObserver = function () {
if (current > 0) {
hint.style.transition = "opacity .4s ease";
hint.style.opacity = "0";
deck.removeEventListener("scroll", hintObserver);
}
};
deck.addEventListener("scroll", hintObserver, { passive: true });
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Tidewater Review — In the Wake: A Visual Story</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=Playfair+Display:ital,wght@0,500;0,600;0,700;0,800;0,900;1,500;1,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#plates">Skip to plates</a>
<!-- Sticky frame counter / nav -->
<div class="frame-hud" role="navigation" aria-label="Plate navigation">
<div class="frame-hud__brand">
<span class="frame-hud__mark">TR</span>
<span class="frame-hud__paper">The Tidewater Review</span>
</div>
<div class="frame-hud__counter" aria-live="polite">
<span class="frame-hud__cur" id="frameCur">01</span>
<span class="frame-hud__sep">/</span>
<span class="frame-hud__total" id="frameTotal">12</span>
</div>
<div class="frame-hud__controls">
<button class="hud-btn" id="prevBtn" type="button" aria-label="Previous plate" title="Previous (←)">↑</button>
<button class="hud-btn" id="nextBtn" type="button" aria-label="Next plate" title="Next (→)">↓</button>
</div>
</div>
<div class="frame-progress" aria-hidden="true"><span class="frame-progress__bar" id="progressBar"></span></div>
<main id="plates" class="deck">
<!-- ============ TITLE SPREAD ============ -->
<section class="plate plate--title" id="plate-1" data-frame="1" aria-label="Title spread">
<div class="title-spread">
<header class="masthead">
<p class="kicker">The Tidewater Review · Photo Essay</p>
<p class="masthead__rule"></p>
</header>
<div class="title-spread__body">
<p class="title-spread__overline">A Visual Story from the Marsh Counties</p>
<h1 class="title-spread__head">In the Wake</h1>
<p class="title-spread__dek">
For three generations the oyster crews of Harlow Point read the water
like scripture. Then the tide stopped keeping its promises. Twelve frames
from a coast learning to remember itself.
</p>
<div class="byline">
<span class="byline__name">Photographs & reporting by Mara Vesper</span>
<span class="byline__dot">·</span>
<span class="byline__date">Harlow Point, October 14</span>
<span class="byline__dot">·</span>
<span class="byline__read">9 min</span>
</div>
<p class="title-spread__hint" id="scrollHint">
<span class="title-spread__hint-key">Scroll</span> or use
<kbd>←</kbd> <kbd>→</kbd> to move between plates ·
click any frame to <em>zoom</em>
</p>
</div>
<figure class="title-spread__plate duo duo--dawn" aria-hidden="true">
<span class="grain"></span>
</figure>
</div>
</section>
<!-- ============ PLATE 2 ============ -->
<section class="plate" id="plate-2" data-frame="2" aria-label="Plate two">
<figure class="frame">
<button class="frame__img duo duo--harbor" type="button" data-zoom aria-label="Zoom plate two: the harbor at first light">
<span class="grain"></span>
<span class="frame__num">02</span>
</button>
<figcaption class="caption">
<span class="caption__text">First light over the cut, where the dredge boats once queued four deep before the bell. On this morning, two.</span>
<span class="caption__credit">Harlow Point, 6:11 a.m. — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ INTERLUDE TEXT ============ -->
<section class="plate plate--text" data-frame="3" id="plate-3" aria-label="Passage">
<div class="passage">
<p class="passage__lead">
The marsh keeps its own ledger. Salt comes in, the cordgrass answers,
and the oysters file themselves into reefs that outlive the men who name them.
For a hundred years the people of Harlow Point worked inside that arithmetic
without ever writing it down, because it never changed enough to need recording.
</p>
<p class="passage__body">
It changed. The water is warmer by a measured degree, the reefs thinner,
the season shorter by weeks. What the old crews knew by feel, the young ones
now learn from a buoy that texts them the salinity. The knowledge did not
disappear. It moved indoors, into spreadsheets and grant applications, and the
water went on rising without consulting either.
</p>
</div>
</section>
<!-- ============ PLATE 4 ============ -->
<section class="plate" id="plate-4" data-frame="4" aria-label="Plate four">
<figure class="frame">
<button class="frame__img duo duo--hull" type="button" data-zoom aria-label="Zoom plate four: hull and waterline">
<span class="grain"></span>
<span class="frame__num">04</span>
</button>
<figcaption class="caption">
<span class="caption__text">The waterline on the <em>Cordelia B.</em> sits a hand's width higher than the paint it was cut to. Her captain repaints the old mark every spring, out of stubbornness, he says.</span>
<span class="caption__credit">Aboard the Cordelia B. — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ PLATE 5 ============ -->
<section class="plate" id="plate-5" data-frame="5" aria-label="Plate five">
<figure class="frame frame--split">
<button class="frame__img duo duo--hands" type="button" data-zoom aria-label="Zoom plate five: shucking hands">
<span class="grain"></span>
<span class="frame__num">05</span>
</button>
<figcaption class="caption">
<span class="caption__text">Forty seasons in the cull house leave a grammar in the hands — a flick, a pry, a toss — that no machine in the new plant has matched.</span>
<span class="caption__credit">Harlow Co-op cull house — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ PULL QUOTE ============ -->
<section class="plate plate--quote" data-frame="6" id="plate-6" aria-label="Pull quote">
<figure class="pullquote">
<blockquote class="pullquote__q">
“You don’t fight the tide. You learn where it’s going
and you get there first.”
</blockquote>
<figcaption class="pullquote__by">
— Estelle Roan, 71, third-generation tonger
</figcaption>
</figure>
</section>
<!-- ============ PLATE 7 ============ -->
<section class="plate" id="plate-7" data-frame="7" aria-label="Plate seven">
<figure class="frame">
<button class="frame__img duo duo--marsh" type="button" data-zoom aria-label="Zoom plate seven: the drowning marsh">
<span class="grain"></span>
<span class="frame__num">07</span>
</button>
<figcaption class="caption">
<span class="caption__text">Where the cordgrass thins, the bare mud spreads in the shape of an open hand. Surveyors call it a “ghost forest” once the cedars go grey. Here the trees went first, in 2019.</span>
<span class="caption__credit">Roan’s Hummock — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ PLATE 8 ============ -->
<section class="plate" id="plate-8" data-frame="8" aria-label="Plate eight">
<figure class="frame frame--split">
<button class="frame__img duo duo--lantern" type="button" data-zoom aria-label="Zoom plate eight: lantern over the seed beds">
<span class="grain"></span>
<span class="frame__num">08</span>
</button>
<figcaption class="caption">
<span class="caption__text">The night seeding runs by hand and by lantern, the way it was done before the co-op had a generator, because the young spat take better when the deck stays dark.</span>
<span class="caption__credit">Restoration flats, 11:40 p.m. — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ INTERLUDE TEXT ============ -->
<section class="plate plate--text" data-frame="9" id="plate-9" aria-label="Passage">
<div class="passage">
<p class="passage__body">
They are building the reef back by hand. Bags of cured shell, sunk on the
ebb, give the spat a wall to climb. It is slow, unglamorous, faithful work,
and it is the only argument the town has found that the tide will listen to.
A reef does not promise. It accretes.
</p>
<p class="passage__body">
Whether it accretes faster than the water rises is the question nobody on the
flats will answer out loud. They answer it instead with their backs, one bag
at a time, on a coast that has stopped keeping its old promises and started,
tentatively, to make new ones.
</p>
</div>
</section>
<!-- ============ PLATE 10 ============ -->
<section class="plate" id="plate-10" data-frame="10" aria-label="Plate ten">
<figure class="frame">
<button class="frame__img duo duo--reef" type="button" data-zoom aria-label="Zoom plate ten: the new reef at low water">
<span class="grain"></span>
<span class="frame__num">10</span>
</button>
<figcaption class="caption">
<span class="caption__text">At dead low the new reef breaks the surface for the first time — a dark ridge of cured shell, eighteen months in the making, holding its line against the cut.</span>
<span class="caption__credit">The Bray Channel reef — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ PLATE 11 ============ -->
<section class="plate" id="plate-11" data-frame="11" aria-label="Plate eleven">
<figure class="frame frame--split">
<button class="frame__img duo duo--portrait" type="button" data-zoom aria-label="Zoom plate eleven: portrait of Estelle Roan">
<span class="grain"></span>
<span class="frame__num">11</span>
</button>
<figcaption class="caption">
<span class="caption__text">Estelle Roan at the door of the cull house her grandfather raised. She has watched the water take the bottom step. She does not expect it to stop at the third.</span>
<span class="caption__credit">Harlow Point — Mara Vesper / The Tidewater Review</span>
</figcaption>
</figure>
</section>
<!-- ============ PLATE 12 / CODA ============ -->
<section class="plate plate--coda" id="plate-12" data-frame="12" aria-label="Plate twelve, coda">
<figure class="frame frame--coda">
<button class="frame__img duo duo--dusk" type="button" data-zoom aria-label="Zoom plate twelve: dusk over the cut">
<span class="grain"></span>
<span class="frame__num">12</span>
</button>
<figcaption class="caption caption--coda">
<span class="caption__text">Last light over the cut. The bell is gone, the queue is gone, but the reef is there now, just under, doing the one thing it has always known how to do: hold on.</span>
<span class="caption__credit">Harlow Point, 6:48 p.m. — Mara Vesper / The Tidewater Review</span>
</figcaption>
<div class="coda__end">
<p class="coda__paper">The Tidewater Review</p>
<p class="coda__note">“In the Wake” · A Photo Essay · 12 plates</p>
<button class="coda__top" id="toTop" type="button">Return to the title spread ↑</button>
</div>
</figure>
</section>
</main>
<!-- ============ LIGHTBOX ============ -->
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Zoomed plate" hidden>
<button class="lightbox__close" id="lbClose" type="button" aria-label="Close zoom (Esc)">×</button>
<figure class="lightbox__fig">
<span class="lightbox__img duo" id="lbImg"><span class="grain"></span></span>
<figcaption class="lightbox__cap" id="lbCap"></figcaption>
</figure>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Photo Essay / Visual Story
A long-form visual story for The Tidewater Review, a fictional coastal paper. It opens on a serif title spread — masthead kicker, the headline In the Wake, a dek, a byline with dateline and read time — then carries the reader through twelve full-bleed plates. Each “photograph” is a hand-built duotone gradient tuned to feel like real press imagery (silvered dawn water, sepia shucking hands, a single lantern in the dark, an ember dusk over the cut), set in an aspect-ratio frame with a plate number, an italic Playfair Display caption and an uppercase credit line.
Plates of imagery are interleaved with short two-column text passages — the lead carries a red
drop cap and justified, hyphenated body copy — and a centred oversized pull quote. A sticky
masthead at the top shows the frame counter (01 / 12) and arrow controls, with a thin red
progress rule beneath it; an IntersectionObserver keeps both in sync as you scroll. Captions
fade up as their plate enters the viewport.
Navigation is built for both pointer and keyboard: arrow keys, space, Page Up/Down and Home/End
move between plates with scroll-snap, while clicking or focusing any frame opens a focus-trapped
lightbox that re-creates the same duotone at full size with its caption. Esc or a click on the
backdrop closes it, focus returns to the frame, and a small toast() helper announces moves. All
interactions are vanilla JS with no external libraries, and the layout collapses from two columns
to one below ~720px and stays usable down to ~360px.
Illustrative UI only — masthead, headlines, bylines, and articles are fictional; not a real news publication.