Paywall — Soft / metered paywall
A self-contained metered paywall for the fictional Northwind Review. A long-read article reads freely while a sticky top bar tracks five free monthly reads on a shrinking progress ring with an aria-live counter. The article tail fades into a gradient veil, and a slide-up bar announces how many reads remain and invites a subscription. A Read another button decrements the meter live; at zero the veil hardens into a full block. A plans modal offers Starter, Pro, and Scale tiers with a monthly versus annual toggle that swaps prices live.
MCP
Code
:root {
--brand: #5b5bf0;
--brand-d: #4646d6;
--brand-700: #3a3ab8;
--brand-50: #eef0ff;
--accent: #00b4a6;
--accent-soft: #d8f5f2;
--ink: #101322;
--ink-2: #3a4060;
--muted: #6c7393;
--bg: #f6f7fb;
--white: #ffffff;
--surface: #ffffff;
--line: rgba(16, 19, 34, 0.1);
--line-2: rgba(16, 19, 34, 0.16);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-sm: 0 1px 2px rgba(16, 19, 34, 0.08);
--sh-md: 0 8px 24px rgba(16, 19, 34, 0.08);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 0 20px 80px;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 600;
font-size: 14px;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 10px 16px;
cursor: pointer;
white-space: nowrap;
transition: transform 0.12s ease, background 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.btn-primary {
background: var(--brand);
color: #fff;
box-shadow: var(--sh-sm);
}
.btn-primary:hover {
background: var(--brand-d);
}
.btn-lg {
padding: 13px 22px;
font-size: 15px;
}
.btn-ghost {
background: var(--white);
color: var(--ink);
border-color: var(--line-2);
}
.btn-ghost:hover {
border-color: var(--brand);
color: var(--brand-d);
}
.btn-outline {
background: transparent;
color: var(--brand-d);
border-color: var(--brand);
}
.btn-outline:hover {
background: var(--brand-50);
}
.btn-link {
background: none;
border: none;
color: var(--muted);
font-size: 13px;
padding: 6px 8px;
text-decoration: underline;
text-underline-offset: 3px;
}
.btn-link:hover {
color: var(--ink-2);
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: var(--r-sm);
background: transparent;
border: 1px solid transparent;
color: var(--muted);
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.icon-btn:hover {
background: var(--bg);
color: var(--ink);
}
.icon-btn:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ---------- Topbar / meter ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 16px;
padding: 14px 0;
margin-bottom: 10px;
background: color-mix(in srgb, var(--bg) 86%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 800;
font-size: 16px;
letter-spacing: -0.01em;
}
.brand em {
font-style: normal;
font-weight: 600;
color: var(--muted);
}
.brand-mark {
display: inline-flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
border-radius: 9px;
background: linear-gradient(135deg, var(--brand), var(--brand-700));
color: #fff;
}
.meter {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.meter-ring {
position: relative;
width: 44px;
height: 44px;
flex: none;
}
.meter-ring svg {
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: var(--line-2);
stroke-width: 4;
}
.ring-fill {
fill: none;
stroke: var(--accent);
stroke-width: 4;
stroke-linecap: round;
stroke-dasharray: 113.1;
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.5s cubic-bezier(0.4, 0, 0.2, 1), stroke 0.3s ease;
}
.meter-ring.is-low .ring-fill {
stroke: var(--warn);
}
.meter-ring.is-out .ring-fill {
stroke: var(--danger);
}
.ring-num {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-weight: 800;
font-size: 15px;
color: var(--ink);
}
.meter-copy {
margin: 0;
font-size: 12px;
line-height: 1.25;
color: var(--muted);
max-width: 130px;
}
.meter-copy strong {
display: block;
color: var(--ink);
font-size: 13px;
}
.btn-sub-top {
flex: none;
}
/* ---------- Article ---------- */
.reader {
position: relative;
}
.article {
position: relative;
}
.kicker {
margin: 8px 0 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--brand-d);
}
.headline {
margin: 8px 0 12px;
font-size: clamp(28px, 6vw, 42px);
line-height: 1.1;
letter-spacing: -0.02em;
font-weight: 800;
}
.deck {
margin: 0 0 22px;
font-size: 18px;
color: var(--ink-2);
max-width: 60ch;
}
.byline {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.avatar {
display: inline-flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--brand-50);
color: var(--brand-700);
font-weight: 700;
font-size: 13px;
flex: none;
}
.byline-name {
margin: 0;
font-weight: 600;
font-size: 14px;
}
.byline-meta {
margin: 2px 0 0;
font-size: 13px;
color: var(--muted);
}
.hero {
height: 220px;
margin: 24px 0;
border-radius: var(--r-md);
background:
radial-gradient(120% 90% at 80% -10%, rgba(0, 180, 166, 0.5), transparent 60%),
radial-gradient(120% 120% at 10% 110%, rgba(91, 91, 240, 0.55), transparent 55%),
linear-gradient(135deg, var(--brand-700), var(--ink));
box-shadow: var(--sh-md);
position: relative;
overflow: hidden;
}
.hero::after {
content: "";
position: absolute;
inset: 0;
background:
repeating-linear-gradient(115deg, rgba(255, 255, 255, 0.06) 0 2px, transparent 2px 22px);
}
.prose {
position: relative;
font-size: 17px;
color: var(--ink-2);
max-width: 64ch;
}
.prose p {
margin: 0 0 18px;
}
.dropcap {
float: left;
font-size: 56px;
line-height: 0.8;
font-weight: 800;
color: var(--ink);
padding: 6px 10px 0 0;
}
/* Gradient veil over the tail */
.veil {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 220px;
pointer-events: none;
background: linear-gradient(to bottom, rgba(246, 247, 251, 0) 0%, rgba(246, 247, 251, 0.7) 45%, var(--bg) 100%);
transition: height 0.45s ease, background 0.45s ease;
opacity: 1;
}
/* Reader has paid / unlimited */
.reader.is-unlimited .veil {
opacity: 0;
}
.reader.is-unlimited .paybar {
transform: translateY(140%);
}
/* Hardened block state */
.reader.is-blocked .veil {
height: 320px;
background: linear-gradient(to bottom, rgba(246, 247, 251, 0) 0%, var(--bg) 38%, var(--bg) 100%);
}
.reader.is-blocked .fade-tail {
filter: blur(3px);
user-select: none;
}
/* ---------- Slide-up paybar ---------- */
.paybar {
position: sticky;
bottom: 16px;
z-index: 15;
margin-top: -40px;
transform: translateY(0);
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
animation: slideUp 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes slideUp {
from {
transform: translateY(120%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.paybar-inner {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
}
.paybar-copy {
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.paybar-eyebrow {
font-weight: 700;
font-size: 14px;
color: var(--ink);
}
.paybar.is-low .paybar-eyebrow {
color: var(--warn);
}
.paybar-line {
font-size: 13px;
color: var(--muted);
}
.paybar-actions {
display: flex;
gap: 10px;
margin-left: auto;
flex: none;
}
/* ---------- Hard block ---------- */
.hardblock {
display: none;
margin-top: -120px;
position: relative;
z-index: 16;
padding-top: 60px;
}
.reader.is-blocked .hardblock {
display: block;
}
.reader.is-blocked .paybar {
display: none;
}
.hardblock-card {
text-align: center;
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: var(--sh-md);
padding: 32px 26px 22px;
max-width: 460px;
margin: 0 auto;
}
.lock {
display: inline-flex;
width: 54px;
height: 54px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--brand-50);
color: var(--brand-d);
margin-bottom: 14px;
}
.hardblock-card h2 {
margin: 0 0 8px;
font-size: 22px;
letter-spacing: -0.01em;
}
.hardblock-card p {
margin: 0 0 18px;
color: var(--muted);
font-size: 15px;
}
.hardblock-card .btn-lg {
width: 100%;
}
.hardblock-card .btn-link {
margin-top: 10px;
}
/* ---------- Overlay + modal ---------- */
.overlay {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(16, 19, 34, 0.45);
backdrop-filter: blur(2px);
animation: fade 0.2s ease both;
}
@keyframes fade {
from {
opacity: 0;
}
}
.modal {
position: fixed;
z-index: 41;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(680px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: 0 24px 60px rgba(16, 19, 34, 0.28);
padding: 24px;
animation: pop 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes pop {
from {
opacity: 0;
transform: translate(-50%, -46%) scale(0.97);
}
}
.modal-head {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 18px;
}
.modal-title {
margin: 0;
font-size: 22px;
letter-spacing: -0.01em;
}
.modal-sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 14px;
}
.modal-head .icon-btn {
margin-left: auto;
flex: none;
}
.cycle {
display: inline-flex;
padding: 4px;
gap: 4px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
margin-bottom: 20px;
}
.cycle-opt {
border: none;
background: transparent;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--muted);
padding: 8px 16px;
border-radius: 999px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background 0.16s ease, color 0.16s ease;
}
.cycle-opt:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.cycle-opt.is-active {
background: var(--white);
color: var(--ink);
box-shadow: var(--sh-sm);
}
.cycle-save {
font-size: 11px;
font-weight: 700;
color: var(--ok);
background: rgba(47, 158, 111, 0.12);
padding: 2px 7px;
border-radius: 999px;
}
.plans {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.plan {
position: relative;
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 20px 16px;
display: flex;
flex-direction: column;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.plan:hover {
border-color: var(--line-2);
box-shadow: var(--sh-sm);
transform: translateY(-2px);
}
.plan-popular {
border-color: var(--brand);
box-shadow: 0 0 0 1px var(--brand), var(--sh-md);
}
.plan-badge {
position: absolute;
top: -11px;
left: 50%;
transform: translateX(-50%);
background: var(--brand);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
padding: 4px 10px;
border-radius: 999px;
white-space: nowrap;
}
.plan-name {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.plan-desc {
margin: 4px 0 14px;
font-size: 13px;
color: var(--muted);
min-height: 34px;
}
.plan-price {
margin: 0 0 16px;
display: flex;
align-items: baseline;
gap: 3px;
}
.plan-price .amount {
font-size: 30px;
font-weight: 800;
letter-spacing: -0.02em;
}
.plan-price .per {
font-size: 13px;
color: var(--muted);
font-weight: 600;
}
.features {
list-style: none;
margin: 0 0 18px;
padding: 0;
display: flex;
flex-direction: column;
gap: 9px;
flex: 1;
}
.features li {
position: relative;
padding-left: 24px;
font-size: 13px;
color: var(--ink-2);
}
.features li::before {
content: "";
position: absolute;
left: 0;
top: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent-soft)
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2300b4a6' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12 4 4 10-11'/%3E%3C/svg%3E")
center / 11px no-repeat;
}
.plan-cta {
width: 100%;
}
.modal-foot {
margin: 18px 0 0;
text-align: center;
font-size: 12px;
color: var(--muted);
}
/* ---------- Toast ---------- */
.toast-wrap {
position: fixed;
z-index: 60;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: #fff;
font-size: 14px;
font-weight: 500;
padding: 12px 18px;
border-radius: 999px;
box-shadow: var(--sh-md);
animation: toastIn 0.28s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.toast.is-out {
animation: toastOut 0.28s ease both;
}
.toast .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateY(14px);
}
}
@keyframes toastOut {
to {
opacity: 0;
transform: translateY(14px);
}
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.page {
padding: 0 14px 64px;
}
.topbar {
flex-wrap: wrap;
gap: 10px;
row-gap: 10px;
}
.meter {
order: 3;
width: 100%;
margin-left: 0;
}
.meter-copy {
max-width: none;
}
.btn-sub-top {
margin-left: auto;
}
.deck {
font-size: 16px;
}
.paybar {
bottom: 10px;
}
.paybar-inner {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding: 14px;
}
.paybar-actions {
margin-left: 0;
flex-wrap: wrap;
}
.paybar-actions .btn {
flex: 1 1 auto;
}
.plans {
grid-template-columns: 1fr;
}
.plan-popular {
order: -1;
}
.plan-desc {
min-height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
var FREE_LIMIT = 5;
var remaining = 4; // reader has already opened this article (1 of 5 used)
// Pricing model: monthly base prices + per-cycle display.
var PRICES = {
starter: { monthly: 6, annual: 5 },
pro: { monthly: 12, annual: 10 },
scale: { monthly: 29, annual: 23 },
};
var cycle = "monthly";
var subscribed = false;
// ---- element refs ----
var reader = document.querySelector(".reader");
var ringFill = document.getElementById("ringFill");
var ringNum = document.getElementById("ringNum");
var meterRing = document.getElementById("meterRing");
var meterCount = document.getElementById("meterCount");
var paybar = document.getElementById("paybar");
var paybarEyebrow = document.getElementById("paybarEyebrow");
var simBtn = document.getElementById("simBtn");
var resetBtn = document.getElementById("resetBtn");
var overlay = document.getElementById("overlay");
var modal = document.getElementById("modal");
var modalClose = document.getElementById("modalClose");
var cycleNote = document.getElementById("cycleNote");
var toastWrap = document.getElementById("toastWrap");
var RING_CIRCUM = 2 * Math.PI * 18; // r=18 → ~113.1
// ---- toast helper ----
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.setAttribute("role", "status");
el.innerHTML = '<span class="dot"></span><span></span>';
el.lastElementChild.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.classList.add("is-out");
el.addEventListener("animationend", function () {
el.remove();
});
}, 2600);
}
// ---- meter rendering ----
function renderMeter() {
var used = FREE_LIMIT - remaining;
var pct = remaining / FREE_LIMIT;
// ring shrinks as reads are used up
ringFill.style.strokeDasharray = RING_CIRCUM.toFixed(2);
ringFill.style.strokeDashoffset = (RING_CIRCUM * (1 - pct)).toFixed(2);
ringNum.textContent = String(remaining);
meterRing.classList.toggle("is-low", remaining > 0 && remaining <= 2);
meterRing.classList.toggle("is-out", remaining === 0);
if (subscribed) {
meterCount.textContent = "Unlimited";
} else {
meterCount.textContent = remaining + " of " + FREE_LIMIT;
}
// paybar copy
paybar.classList.toggle("is-low", remaining > 0 && remaining <= 2);
if (remaining === 1) {
paybarEyebrow.textContent = "You have 1 free read left";
} else if (remaining > 0) {
paybarEyebrow.textContent = "You have " + remaining + " free reads left";
} else {
paybarEyebrow.textContent = "You're out of free reads";
}
// hard block at zero
reader.classList.toggle("is-blocked", remaining === 0 && !subscribed);
void used;
}
// ---- simulate reading another article ----
function readAnother() {
if (subscribed) {
toast("You have unlimited reads.");
return;
}
if (remaining <= 0) {
openModal();
return;
}
remaining -= 1;
renderMeter();
if (remaining === 0) {
toast("That was your last free read.");
} else if (remaining <= 2) {
toast(remaining + " free read" + (remaining === 1 ? "" : "s") + " left this month.");
} else {
toast("Article opened — " + remaining + " free reads left.");
}
}
// ---- modal ----
var lastFocus = null;
function openModal() {
lastFocus = document.activeElement;
overlay.hidden = false;
modal.hidden = false;
modalClose.focus();
document.addEventListener("keydown", onKeydown);
}
function closeModal() {
overlay.hidden = true;
modal.hidden = true;
document.removeEventListener("keydown", onKeydown);
if (lastFocus && typeof lastFocus.focus === "function") {
lastFocus.focus();
}
}
function onKeydown(e) {
if (e.key === "Escape") {
closeModal();
return;
}
if (e.key === "Tab") {
trapFocus(e);
}
}
function trapFocus(e) {
var focusables = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
var first = focusables[0];
var last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
// ---- billing cycle toggle ----
function setCycle(next) {
cycle = next;
document.querySelectorAll(".cycle-opt").forEach(function (b) {
var on = b.getAttribute("data-cycle") === next;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
document.querySelectorAll("[data-price]").forEach(function (el) {
var key = el.getAttribute("data-price");
el.textContent = "$" + PRICES[key][cycle];
});
document.querySelectorAll("[data-per]").forEach(function (el) {
el.textContent = cycle === "annual" ? "/mo, billed yearly" : "/mo";
});
cycleNote.textContent = cycle === "annual" ? "annually" : "monthly";
}
// ---- subscribe flow ----
function subscribe(planName) {
subscribed = true;
remaining = FREE_LIMIT;
reader.classList.remove("is-blocked");
reader.classList.add("is-unlimited");
renderMeter();
closeModal();
var suffix = planName ? " — " + planName + " (" + cycle + ")" : "";
toast("You're subscribed" + suffix + ". Enjoy unlimited reading!");
}
// ---- wiring ----
simBtn.addEventListener("click", readAnother);
resetBtn.addEventListener("click", function () {
subscribed = false;
remaining = FREE_LIMIT - 1;
reader.classList.remove("is-unlimited", "is-blocked");
renderMeter();
toast("Meter reset to " + remaining + " of " + FREE_LIMIT + ".");
});
document.querySelectorAll("[data-subscribe]").forEach(function (b) {
b.addEventListener("click", openModal);
});
modalClose.addEventListener("click", closeModal);
overlay.addEventListener("click", closeModal);
document.querySelectorAll(".cycle-opt").forEach(function (b) {
b.addEventListener("click", function () {
setCycle(b.getAttribute("data-cycle"));
});
});
document.querySelectorAll(".plan-cta").forEach(function (b) {
b.addEventListener("click", function () {
subscribe(b.getAttribute("data-plan"));
});
});
// ---- init ----
setCycle("monthly");
renderMeter();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Northwind — Soft / Metered Paywall</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=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<!-- Top meter bar -->
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 12a9 9 0 1 0 18 0 9 9 0 0 0-18 0Z" />
<path d="m8 12 3 3 5-6" />
</svg>
</span>
<span class="brand-name">Northwind <em>Review</em></span>
</div>
<div class="meter" aria-hidden="false">
<div class="meter-ring" id="meterRing">
<svg viewBox="0 0 44 44" width="44" height="44">
<circle class="ring-track" cx="22" cy="22" r="18" />
<circle class="ring-fill" id="ringFill" cx="22" cy="22" r="18" />
</svg>
<span class="ring-num" id="ringNum">5</span>
</div>
<p class="meter-copy">
<strong id="meterCount" aria-live="polite" aria-atomic="true">5 of 5</strong>
<span>free articles left this month</span>
</p>
</div>
<button class="btn btn-ghost btn-sub-top" data-subscribe type="button">Subscribe</button>
</header>
<main class="reader">
<article class="article" aria-labelledby="headline">
<p class="kicker">Cities · Long read</p>
<h1 class="headline" id="headline">The Quiet Engineers Keeping Lumen Bay Above Water</h1>
<p class="deck">A fictional dispatch on the maintenance crews, tide models, and stubborn optimism
holding back the sea — and what the next decade asks of them.</p>
<div class="byline">
<span class="avatar" aria-hidden="true">MR</span>
<div>
<p class="byline-name">By Mara Oduya & Theo Vance</p>
<p class="byline-meta">June 13, 2026 · 11 min read</p>
</div>
</div>
<div class="hero" role="img" aria-label="Stylized illustration of a harbor wall at dusk"></div>
<div class="prose" id="prose">
<p><span class="dropcap">B</span>efore dawn, while the Lumen Bay boardwalk is still owned by
gulls and joggers, a four-person crew climbs into the tide gate housing beneath Pier 9. They are
checking a seal the size of a dinner plate — the kind of part nobody thinks about until the
morning it fails. "People picture a giant red lever," says crew lead Mara Oduya. "It's mostly
us, a torque wrench, and a spreadsheet from 1998."</p>
<p>The bay's flood defenses are not one machine but a hundred small ones, stitched together
across forty years of budgets, retrofits, and promises. Each gate, pump, and sensor carries its
own quirks. The crews keep a shared logbook of these moods — pump 14 that hums at low tide,
valve 6 that needs a second nudge in winter — a body of knowledge that lives mostly in people,
not manuals.</p>
<p>That fragility is the story of infrastructure everywhere. The headlines arrive only when
something breaks; the work is the decades when nothing does. Lumen Bay has been lucky, and its
engineers are quick to say so. But luck, Oduya notes, is just maintenance you haven't skipped
yet.</p>
<p>This year the city commissioned a new tide model — the first to fold in the sharper storm
surges of the last five winters. The results were not a surprise so much as a confirmation: the
margins that felt generous in 1998 now look like rounding errors. The crews already knew. They
had been watching the high-water marks creep up the pilings, season after season.</p>
<p class="fade-tail">What the model can't capture is the thing the crews trade in: judgment,
built from thousands of small mornings under the pier. The question facing Lumen Bay is whether
that knowledge can be handed down faster than the water rises — and who, exactly, is being
trained to carry it once the spreadsheet from 1998 finally retires.</p>
</div>
<!-- Gradient veil + slide-up bar -->
<div class="veil" id="veil" aria-hidden="true"></div>
</article>
<!-- Slide-up subscribe bar -->
<div class="paybar" id="paybar" role="region" aria-label="Subscription offer">
<div class="paybar-inner">
<p class="paybar-copy">
<span class="paybar-eyebrow" id="paybarEyebrow" aria-live="polite">You have 4 free reads left</span>
<span class="paybar-line">Subscribe to Northwind for unlimited reading.</span>
</p>
<div class="paybar-actions">
<button class="btn btn-ghost btn-sim" id="simBtn" type="button">Read another (simulate)</button>
<button class="btn btn-primary" data-subscribe type="button">Subscribe</button>
</div>
</div>
</div>
<!-- Hard block (revealed at 0) -->
<div class="hardblock" id="hardblock" aria-hidden="true">
<div class="hardblock-card">
<span class="lock" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="11" width="16" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
</span>
<h2>You've used all 5 free reads this month</h2>
<p>Keep reading Lumen Bay — and every Northwind story — without limits. Cancel anytime.</p>
<button class="btn btn-primary btn-lg" data-subscribe type="button">See plans</button>
<button class="btn btn-link" id="resetBtn" type="button">Reset meter (demo)</button>
</div>
</div>
</main>
<!-- Plans modal -->
<div class="overlay" id="overlay" hidden></div>
<div class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" hidden>
<div class="modal-head">
<div>
<h2 class="modal-title" id="modalTitle">Choose your plan</h2>
<p class="modal-sub">Unlimited articles, archive access, and no meter — ever.</p>
</div>
<button class="icon-btn" id="modalClose" type="button" aria-label="Close dialog">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</div>
<div class="cycle" role="group" aria-label="Billing cycle">
<button class="cycle-opt is-active" data-cycle="monthly" type="button" aria-pressed="true">Monthly</button>
<button class="cycle-opt" data-cycle="annual" type="button" aria-pressed="false">
Annual <span class="cycle-save">Save 20%</span>
</button>
</div>
<div class="plans">
<article class="plan">
<h3 class="plan-name">Starter</h3>
<p class="plan-desc">For the occasional reader.</p>
<p class="plan-price"><span class="amount" data-price="starter">$6</span><span class="per" data-per>/mo</span></p>
<ul class="features">
<li>Unlimited articles</li>
<li>Daily newsletter</li>
<li>1 reading device</li>
</ul>
<button class="btn btn-outline plan-cta" data-plan="Starter" type="button">Choose Starter</button>
</article>
<article class="plan plan-popular">
<span class="plan-badge">Most popular</span>
<h3 class="plan-name">Pro</h3>
<p class="plan-desc">Everything, for the daily habit.</p>
<p class="plan-price"><span class="amount" data-price="pro">$12</span><span class="per" data-per>/mo</span></p>
<ul class="features">
<li>Everything in Starter</li>
<li>Full 20-year archive</li>
<li>Offline & audio reads</li>
<li>5 reading devices</li>
</ul>
<button class="btn btn-primary plan-cta" data-plan="Pro" type="button">Choose Pro</button>
</article>
<article class="plan">
<h3 class="plan-name">Scale</h3>
<p class="plan-desc">For teams & newsrooms.</p>
<p class="plan-price"><span class="amount" data-price="scale">$29</span><span class="per" data-per>/mo</span></p>
<ul class="features">
<li>Everything in Pro</li>
<li>Up to 10 seats</li>
<li>Shared clip library</li>
<li>Priority support</li>
</ul>
<button class="btn btn-outline plan-cta" data-plan="Scale" type="button">Choose Scale</button>
</article>
</div>
<p class="modal-foot">Prices in USD · billed <span id="cycleNote">monthly</span> · cancel anytime.</p>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
</div>
<script src="script.js"></script>
</body>
</html>Soft / metered paywall
A complete soft-paywall reading view for the fictional Northwind Review, art-directed in the neutral
product palette with an indigo brand and a teal accent. A long-read about the engineers keeping Lumen Bay
above water opens normally — kicker, oversized headline, deck, byline, and a CSS-only duotone hero — while
a sticky top bar tracks the reader’s monthly allowance on a circular progress ring. The ring shrinks and
shifts from teal to amber to red as reads are used, and an aria-live counter announces “N of 5 free
articles left” for screen readers.
The article’s final paragraphs dissolve into a gradient veil, and a sticky bar slides up from the bottom
announcing how many free reads remain and inviting a subscription. The “Read another (simulate)” button
decrements the meter and pushes a toast on each tap; when the count reaches zero the veil deepens, the
tail paragraph blurs, and the soft fade hardens into a full lock card — the metered nudge becoming a hard
gate. A plans modal (role="dialog", aria-modal, focus trap, Esc to close) presents Starter, Pro, and
a highlighted “Most popular” Scale-tier lineup, with a Monthly/Annual toggle that swaps every price live
and a “Save 20%” badge on the annual option. Subscribing clears the meter to unlimited and lifts the veil.
Everything is vanilla HTML, CSS, and JS with inline SVG icons — no frameworks, no build step, and no
network requests beyond the Inter web font. A prefers-reduced-motion block neutralizes the slide and
fade animations, and the layout collapses cleanly to a single column below 520px.
Illustrative UI only — the brand, article, prices, and bylines are fictional; not a real publication.