<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="np-i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
<symbol id="np-i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="np-i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></symbol>
<symbol id="np-i-vase" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v3c-3 1.5-5 4.5-5 8a7 7 0 0 0 14 0c0-3.5-2-6.5-5-8V3M6 14h12"/></symbol>
</svg>
<div>
<span class="lbl">Centered popup</span>
<div class="demo-row"><button class="np-trigger" type="button" data-open="np-dispatch" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Open the popup</button><p>Modal: Esc, the close button, Not now or a backdrop click closes it.</p></div>
<div class="stage stage--center" data-preview="np-dispatch" aria-hidden="true" inert></div>
</div>
<div>
<span class="lbl">Corner slide-in</span>
<div class="demo-row"><button class="np-trigger" type="button" data-open="np-kiln" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Show the slide-in</button><p>Non-modal: the page stays usable. Esc or the close button dismisses it.</p></div>
<div class="stage stage--corner" data-preview="np-kiln" aria-hidden="true" inert></div>
</div>
<div>
<span class="lbl">Bottom bar</span>
<div class="demo-row"><button class="np-trigger" type="button" data-open="np-halden" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Show the bar</button><p>Non-modal, full width, for a quieter ask.</p></div>
<div class="stage stage--bar" data-preview="np-halden" aria-hidden="true" inert></div>
</div>
<!-- 1. Centered popup (showModal) -->
<dialog class="np np-modal" id="np-dispatch" data-modal closedby="any" aria-labelledby="np-dispatch-title">
<div class="np-split">
<div class="np-art" aria-hidden="true"><b>Northline</b><span>Read by 52,000 walkers</span></div>
<div class="np-main">
<button class="np-x" type="button" data-close aria-label="Close"><svg aria-hidden="true"><use href="#np-i-x"/></svg></button>
<form class="np-form" action="#" method="post" data-nf="np-dispatch-done">
<p class="np-kicker">Free, every Sunday</p>
<h2 class="np-title" id="np-dispatch-title">Get the Sunday Dispatch</h2>
<p class="np-text">Three routes worth the drive, one gear test we paid for ourselves, and a long read for the evening.</p>
<div class="np-field">
<label class="np-label" for="np-dispatch-email">Email address</label>
<div class="np-row">
<input class="np-input" id="np-dispatch-email" name="email" type="email" autocomplete="email" required autofocus aria-describedby="np-dispatch-err np-dispatch-hint" data-err="np-dispatch-err"
data-missing="Enter your email address to subscribe." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="np-btn" type="submit">Subscribe</button>
</div>
<p class="np-err" id="np-dispatch-err"></p>
</div>
<div class="np-foot">
<p class="np-hint" id="np-dispatch-hint">One email a week. <a href="#privacy">Privacy notice</a></p>
<button class="np-plain" type="button" data-close>Not now</button>
</div>
</form>
<div class="np-done" id="np-dispatch-done" tabindex="-1" hidden>
<span class="np-done-ico" aria-hidden="true"><svg><use href="#np-i-tick"/></svg></span>
<h2 class="np-title">Check your inbox</h2>
<p class="np-text">We sent a confirmation link to <b data-email>you</b>. The next Dispatch goes out on Sunday at 7am.</p>
<div class="np-foot"><button class="np-btn" type="button" data-close>Back to the article</button></div>
</div>
</div>
</div>
</dialog>
<!-- 2. Corner slide-in (show) -->
<dialog class="np np-corner" id="np-kiln" aria-labelledby="np-kiln-title">
<div class="np-corner-in">
<button class="np-x" type="button" data-close aria-label="Close"><svg aria-hidden="true"><use href="#np-i-x"/></svg></button>
<div class="np-corner-head">
<span class="np-badge" aria-hidden="true"><svg><use href="#np-i-vase"/></svg></span>
<div><h2 class="np-title" id="np-kiln-title">Enjoying part 2?</h2>
<p class="np-text">Get the other four parts of our glazing series by email as each one is published.</p></div>
</div>
<form class="np-form" action="#" method="post" data-nf="np-kiln-done">
<label class="np-label" for="np-kiln-email">Email address</label>
<div class="np-row">
<input class="np-input" id="np-kiln-email" name="email" type="email" autocomplete="email" required aria-describedby="np-kiln-err np-kiln-hint" data-err="np-kiln-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="np-btn" type="submit">Send me the series</button>
</div>
<p class="np-err" id="np-kiln-err"></p>
<p class="np-hint" id="np-kiln-hint">Five emails from Kiln & Clay, then nothing unless you ask.</p>
</form>
<div class="np-done" id="np-kiln-done" tabindex="-1" hidden>
<p class="np-text"><b>Done.</b> Part 3 will reach <b data-email>you</b> on 2 October, once you confirm the email we just sent.</p>
</div>
</div>
</dialog>
<!-- 3. Bottom bar (show) -->
<dialog class="np np-bar" id="np-halden" aria-labelledby="np-halden-title">
<div class="np-bar-in">
<button class="np-x" type="button" data-close aria-label="Close"><svg aria-hidden="true"><use href="#np-i-x"/></svg></button>
<div class="np-bar-copy"><h2 class="np-title" id="np-halden-title">The Thursday deals digest</h2>
<p class="np-text">Real discounts on tents, boots and stoves from Halden Outdoor. Weekly, never daily.</p></div>
<form class="np-form" action="#" method="post" data-nf="np-halden-done">
<label class="np-label" for="np-halden-email">Email address</label>
<div class="np-row">
<input class="np-input" id="np-halden-email" name="email" type="email" autocomplete="email" required placeholder="Email address" aria-describedby="np-halden-err" data-err="np-halden-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="np-btn" type="submit">Get the digest</button>
</div>
<p class="np-err" id="np-halden-err"></p>
</form>
<div class="np-done" id="np-halden-done" tabindex="-1" hidden>
<span class="np-done-ico" aria-hidden="true"><svg><use href="#np-i-tick"/></svg></span>
<p class="np-text">Confirm the email we sent to <b data-email>you</b> and Thursday's digest is yours.</p>
</div>
</div>
</dialog>
CSS
/* Newsletter popups built on the native <dialog>.
- The centered popup opens with showModal(): focus moves into it, the page behind is inert, Esc closes it, and
a click on the backdrop closes it too. On close, focus goes back to the button that opened it.
- The corner slide-in and the bottom bar open with show(): the page stays usable, Esc and the close button dismiss them.
- No dark patterns: a visible close button, a plain "Not now", no pre-ticked boxes, no guilt-trip copy, and nothing
opens on page load. Open them from a button, or after a scroll depth you choose, and remember the dismissal. */
html:has(.np-modal[open]){overflow:hidden}
.np{--np-ink:#1a1d24;--np-muted:#535866;--np-line:#7b808d;--np-accent:#be123c;--np-accent-dark:#9f1239;--np-ring:rgba(190,18,60,.22);--np-error:#b42318;--np-ok:#15803d;
padding:0;border:0;background:#fff;color:var(--np-ink);font-size:.92rem;line-height:1.5;box-shadow:0 0 0 1px rgba(26,29,36,.06),0 30px 70px -24px rgba(26,29,36,.55)}
.np *,.np *::before,.np *::after{box-sizing:border-box}
.np a{color:var(--np-accent-dark);font-weight:600;text-underline-offset:2px}
.np a:focus-visible{outline:2px solid var(--np-accent);outline-offset:2px;border-radius:3px}
.np-trigger{display:inline-flex;align-items:center;gap:8px;font:inherit;font-size:.9rem;font-weight:650;color:#fff;background:#be123c;border:0;border-radius:10px;padding:10px 16px;cursor:pointer;
transition:background-color .15s}
.np-trigger:hover{background:#9f1239}
.np-trigger:focus-visible{outline:2px solid #be123c;outline-offset:3px}
.np-trigger svg{width:18px;height:18px}
.np-x{position:absolute;top:10px;right:10px;z-index:2;display:grid;place-items:center;width:36px;height:36px;padding:0;border:0;border-radius:10px;background:rgba(255,255,255,.9);
color:var(--np-muted);cursor:pointer;transition:background-color .15s,color .15s}
.np-x:hover{background:#eceef2;color:var(--np-ink)}
.np-x:focus-visible{outline:2px solid var(--np-accent);outline-offset:1px}
.np-x svg{width:20px;height:20px}
.np-kicker{font-size:.72rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--np-accent-dark)}
.np-title{font-size:1.4rem;font-weight:750;letter-spacing:-.02em;line-height:1.2;margin:4px 0 6px}
.np-text{color:var(--np-muted)}
.np-label{display:block;font-size:.84rem;font-weight:650;margin-bottom:6px}
.np-row{display:flex;gap:8px}
.np-input{display:block;flex:1 1 0;width:100%;min-width:0;font:inherit;color:var(--np-ink);background:#fff;border:1.5px solid var(--np-line);border-radius:10px;padding:10px 13px;
transition:border-color .15s,box-shadow .15s}
.np-input:focus{outline:none;border-color:var(--np-accent);box-shadow:0 0 0 4px var(--np-ring)}
.np-input.is-invalid{border-color:var(--np-error)}
.np-btn{flex:none;font:inherit;font-weight:650;color:#fff;background:var(--np-accent);border:0;border-radius:10px;padding:10px 18px;cursor:pointer;transition:background-color .15s}
.np-btn:hover{background:var(--np-accent-dark)}
.np-btn:focus-visible{outline:2px solid var(--np-accent);outline-offset:3px}
.np-plain{font:inherit;font-size:.86rem;font-weight:600;color:var(--np-muted);background:none;border:0;border-radius:6px;padding:4px 6px;margin-left:-6px;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.np-plain:hover{color:var(--np-ink)}
.np-plain:focus-visible{outline:2px solid var(--np-accent);outline-offset:1px}
.np-err{display:flex;gap:7px;align-items:flex-start;font-size:.82rem;font-weight:600;color:var(--np-error);margin-top:7px}
.np-err:empty{display:none}
.np-err::before{content:"!";flex:none;display:grid;place-items:center;width:16px;height:16px;margin-top:2px;border-radius:50%;background:var(--np-error);color:#fff;font-size:.7rem;font-weight:800}
.np-hint{font-size:.78rem;color:var(--np-muted);margin-top:8px}
.np-done{outline:none}
.np-done[hidden],.np-form[hidden]{display:none}
.np-done:focus-visible{outline:2px solid var(--np-accent);outline-offset:4px;border-radius:10px}
.np-done-ico{display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:#dcfce7;color:var(--np-ok);margin-bottom:10px}
.np-done-ico svg{width:22px;height:22px}
.np-done b[data-email]{overflow-wrap:anywhere}
/* 1. Centered popup: illustration panel beside the form */
.np-modal{position:fixed;inset:0;width:min(680px,calc(100% - 32px));max-height:calc(100dvh - 32px);margin:auto;border-radius:20px;overflow:auto;overscroll-behavior:contain}
.np-modal[open]{animation:np-pop .22s cubic-bezier(.2,.8,.2,1)}
.np-modal::backdrop{background:rgba(17,20,28,.55)}
.np-split{display:grid;grid-template-columns:240px 1fr}
.np-art{position:relative;min-height:100%;padding:22px;color:#fff;display:flex;flex-direction:column;justify-content:flex-end;
background:radial-gradient(circle at 70% 26%,#ffe4b5 0 22px,rgba(255,228,181,.25) 23px 36px,transparent 37px),
linear-gradient(160deg,transparent 52%,#3f1d2e 52%) ,linear-gradient(200deg,transparent 60%,#5b2140 60%),linear-gradient(180deg,#f59e7a,#be4a6a 60%,#7a1f45)}
.np-art b{font-size:1.05rem;line-height:1.25}
.np-art span{font-size:.8rem;opacity:.9}
.np-main{position:relative;padding:34px 30px 26px}
.np-field{margin-top:16px}
.np-foot{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:6px 12px;margin-top:14px}
.np-foot .np-hint{margin:0}
/* 2. Corner slide-in: non-modal, bottom right */
.np-corner{position:fixed;inset:auto 20px 20px auto;width:min(360px,calc(100% - 24px));margin:0;border-radius:16px}
.np-corner[open]{animation:np-slide .3s cubic-bezier(.2,.8,.2,1)}
.np-corner-in{position:relative;padding:20px 20px 18px}
.np-corner-head{display:flex;gap:12px;align-items:flex-start;padding-right:30px}
.np-badge{flex:none;display:grid;place-items:center;width:40px;height:40px;border-radius:12px;background:#ffe4e6;color:var(--np-accent-dark)}
.np-badge svg{width:22px;height:22px}
.np-corner .np-title{font-size:1.05rem;margin:0 0 2px}
.np-corner .np-text{font-size:.86rem}
.np-corner .np-form{margin-top:14px}
.np-corner .np-done{margin-top:12px}
.np-corner .np-row{flex-direction:column}
.np-corner .np-x{top:8px;right:8px}
/* 3. Bottom bar: non-modal, full width */
.np-bar{position:fixed;inset:auto 0 0 0;width:100%;max-width:none;margin:0;border-radius:16px 16px 0 0;border-top:3px solid var(--np-accent)}
.np-bar[open]{animation:np-rise .3s cubic-bezier(.2,.8,.2,1)}
.np-bar-in{position:relative;display:flex;flex-wrap:wrap;align-items:center;gap:12px 28px;max-width:1040px;margin:0 auto;padding:18px 60px 18px 24px}
.np-bar-copy{flex:1 1 280px;min-width:0}
.np-bar .np-title{font-size:1.05rem;margin:0}
.np-bar .np-text{font-size:.86rem}
.np-bar .np-form,.np-bar .np-done{flex:1 1 340px;min-width:0}
.np-bar .np-label{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap}
.np-bar .np-x{top:50%;right:12px;margin-top:-18px}
.np-bar .np-done:not([hidden]){display:flex;gap:12px;align-items:center}
.np-bar .np-done .np-done-ico{margin:0;flex:none}
@keyframes np-pop{from{opacity:0;transform:translateY(10px) scale(.97)}}
@keyframes np-slide{from{opacity:0;transform:translateX(24px)}}
@keyframes np-rise{from{transform:translateY(100%)}}
@media (max-width:600px){.np-split{grid-template-columns:1fr}.np-art{min-height:120px;padding:18px}.np-main{padding:26px 20px 22px}
.np-corner{inset:auto 12px 12px 12px;width:auto}.np-bar-in{padding:16px 56px 16px 18px}.np-bar .np-x{top:12px;margin-top:0}}
@media (max-width:420px){.np-row{flex-direction:column}}
@media (prefers-reduced-motion:reduce){.np-modal[open],.np-corner[open],.np-bar[open]{animation:none}.np-trigger,.np-x,.np-input,.np-btn{transition:none}}
JavaScript
// Opening and closing: buttons with data-open="id" open a dialog; showModal() when it has data-modal, show() otherwise.
// Esc closes both kinds, a backdrop click closes the modal one, and focus returns to the button that opened it.
// The forms are a front-end demo: nothing is sent. Errors are written into the element each input points to with
// aria-describedby; a valid submit swaps in the success state. Replace that branch with your fetch() call.
(function () {
document.querySelectorAll('[data-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
var dialog = document.getElementById(btn.getAttribute('data-open'));
if (!dialog || dialog.open) return;
dialog.opener = btn;
if (dialog.hasAttribute('data-modal')) dialog.showModal(); else dialog.show();
if (!dialog.hasAttribute('data-modal')) { var first = dialog.querySelector('input,button:not([data-close])'); if (first) first.focus(); }
});
});
document.querySelectorAll('dialog.np').forEach(function (dialog) {
dialog.querySelectorAll('[data-close]').forEach(function (b) { b.addEventListener('click', function () { dialog.close(); }); });
dialog.addEventListener('close', function () { if (dialog.opener) dialog.opener.focus(); });
// Esc for non-modal dialogs (modal ones get it from the browser)
dialog.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !dialog.matches(':modal')) { e.preventDefault(); dialog.close(); } });
// light dismiss: a click that lands on the dialog element itself is a click on the backdrop
dialog.addEventListener('click', function (e) { if (e.target === dialog && dialog.matches(':modal')) dialog.close(); });
});
function check(el) {
var msg = el.checkValidity() ? '' : el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid');
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
if (!check(email)) { email.focus(); return; }
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
email.addEventListener('input', function () { if (email.classList.contains('is-invalid')) check(email); });
});
})();
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="np-i-mail" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3.5 6.5L12 13l8.5-6.5"/></symbol>
<symbol id="np-i-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></symbol>
<symbol id="np-i-vase" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3h6M10 3v3c-3 1.5-5 4.5-5 8a7 7 0 0 0 14 0c0-3.5-2-6.5-5-8V3M6 14h12"/></symbol>
</svg>
<div>
<span class="lbl">Centered popup</span>
<div class="demo-row"><button class="btn np-trigger" type="button" data-open="np-dispatch" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Open the popup</button><p>Modal: Esc, the close button, Not now or a backdrop click closes it.</p></div>
<div class="stage stage--center" data-preview="np-dispatch" aria-hidden="true" inert></div>
</div>
<div>
<span class="lbl">Corner slide-in</span>
<div class="demo-row"><button class="btn np-trigger" type="button" data-open="np-kiln" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Show the slide-in</button><p>Non-modal: the page stays usable. Esc or the close button dismisses it.</p></div>
<div class="stage stage--corner" data-preview="np-kiln" aria-hidden="true" inert></div>
</div>
<div>
<span class="lbl">Bottom bar</span>
<div class="demo-row"><button class="btn np-trigger" type="button" data-open="np-halden" aria-haspopup="dialog"><svg aria-hidden="true"><use href="#np-i-mail"/></svg>Show the bar</button><p>Non-modal, full width, for a quieter ask.</p></div>
<div class="stage stage--bar" data-preview="np-halden" aria-hidden="true" inert></div>
</div>
<!-- 1. Centered popup (showModal) -->
<dialog class="np np-modal" id="np-dispatch" data-modal closedby="any" aria-labelledby="np-dispatch-title">
<div class="np-split">
<div class="np-art" aria-hidden="true"><b>Northline</b><span>Read by 52,000 walkers</span></div>
<div class="np-main">
<button class="btn-close" type="button" data-close aria-label="Close"></button>
<form action="#" method="post" data-nf="np-dispatch-done">
<p class="np-kicker">Free, every Sunday</p>
<h2 class="np-title" id="np-dispatch-title">Get the Sunday Dispatch</h2>
<p class="np-text">Three routes worth the drive, one gear test we paid for ourselves, and a long read for the evening.</p>
<div class="np-field">
<label class="form-label" for="np-dispatch-email">Email address</label>
<div class="d-flex gap-2 np-row">
<input class="form-control" id="np-dispatch-email" name="email" type="email" autocomplete="email" required autofocus aria-describedby="np-dispatch-err np-dispatch-hint" data-err="np-dispatch-err"
data-missing="Enter your email address to subscribe." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="btn np-btn" type="submit">Subscribe</button>
</div>
<p class="np-err" id="np-dispatch-err"></p>
</div>
<div class="np-foot">
<p class="form-text" id="np-dispatch-hint">One email a week. <a href="#privacy">Privacy notice</a></p>
<button class="btn np-plain" type="button" data-close>Not now</button>
</div>
</form>
<div class="np-done" id="np-dispatch-done" tabindex="-1" hidden>
<span class="np-done-ico" aria-hidden="true"><svg><use href="#np-i-tick"/></svg></span>
<h2 class="np-title">Check your inbox</h2>
<p class="np-text">We sent a confirmation link to <b data-email>you</b>. The next Dispatch goes out on Sunday at 7am.</p>
<div class="np-foot"><button class="btn np-btn" type="button" data-close>Back to the article</button></div>
</div>
</div>
</div>
</dialog>
<!-- 2. Corner slide-in (show) -->
<dialog class="np np-corner" id="np-kiln" aria-labelledby="np-kiln-title">
<div class="np-corner-in">
<button class="btn-close" type="button" data-close aria-label="Close"></button>
<div class="np-corner-head">
<span class="np-badge" aria-hidden="true"><svg><use href="#np-i-vase"/></svg></span>
<div><h2 class="np-title" id="np-kiln-title">Enjoying part 2?</h2>
<p class="np-text">Get the other four parts of our glazing series by email as each one is published.</p></div>
</div>
<form action="#" method="post" data-nf="np-kiln-done">
<label class="form-label" for="np-kiln-email">Email address</label>
<div class="d-flex flex-column gap-2">
<input class="form-control" id="np-kiln-email" name="email" type="email" autocomplete="email" required aria-describedby="np-kiln-err np-kiln-hint" data-err="np-kiln-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="btn np-btn" type="submit">Send me the series</button>
</div>
<p class="np-err" id="np-kiln-err"></p>
<p class="form-text" id="np-kiln-hint">Five emails from Kiln & Clay, then nothing unless you ask.</p>
</form>
<div class="np-done" id="np-kiln-done" tabindex="-1" hidden>
<p class="np-text"><b>Done.</b> Part 3 will reach <b data-email>you</b> on 2 October, once you confirm the email we just sent.</p>
</div>
</div>
</dialog>
<!-- 3. Bottom bar (show) -->
<dialog class="np np-bar" id="np-halden" aria-labelledby="np-halden-title">
<div class="np-bar-in">
<button class="btn-close" type="button" data-close aria-label="Close"></button>
<div class="np-bar-copy"><h2 class="np-title" id="np-halden-title">The Thursday deals digest</h2>
<p class="np-text">Real discounts on tents, boots and stoves from Halden Outdoor. Weekly, never daily.</p></div>
<form action="#" method="post" data-nf="np-halden-done">
<label class="visually-hidden" for="np-halden-email">Email address</label>
<div class="d-flex gap-2 np-row">
<input class="form-control" id="np-halden-email" name="email" type="email" autocomplete="email" required placeholder="Email address" aria-describedby="np-halden-err" data-err="np-halden-err"
data-missing="Enter your email address." data-bad="Enter an email address with an @ and a domain, like [email protected].">
<button class="btn np-btn" type="submit">Get the digest</button>
</div>
<p class="np-err" id="np-halden-err"></p>
</form>
<div class="np-done" id="np-halden-done" tabindex="-1" hidden>
<span class="np-done-ico" aria-hidden="true"><svg><use href="#np-i-tick"/></svg></span>
<p class="np-text">Confirm the email we sent to <b data-email>you</b> and Thursday's digest is yours.</p>
</div>
</div>
</dialog>
// Opening and closing: buttons with data-open="id" open a dialog; showModal() when it has data-modal, show() otherwise.
// Esc closes both kinds, a backdrop click closes the modal one, and focus returns to the button that opened it.
// The forms are a front-end demo: nothing is sent. Errors are written into the element each input points to with
// aria-describedby; a valid submit swaps in the success state. Replace that branch with your fetch() call.
(function () {
document.querySelectorAll('[data-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
var dialog = document.getElementById(btn.getAttribute('data-open'));
if (!dialog || dialog.open) return;
dialog.opener = btn;
if (dialog.hasAttribute('data-modal')) dialog.showModal(); else dialog.show();
if (!dialog.hasAttribute('data-modal')) { var first = dialog.querySelector('input,button:not([data-close])'); if (first) first.focus(); }
});
});
document.querySelectorAll('dialog.np').forEach(function (dialog) {
dialog.querySelectorAll('[data-close]').forEach(function (b) { b.addEventListener('click', function () { dialog.close(); }); });
dialog.addEventListener('close', function () { if (dialog.opener) dialog.opener.focus(); });
// Esc for non-modal dialogs (modal ones get it from the browser)
dialog.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !dialog.matches(':modal')) { e.preventDefault(); dialog.close(); } });
// light dismiss: a click that lands on the dialog element itself is a click on the backdrop
dialog.addEventListener('click', function (e) { if (e.target === dialog && dialog.matches(':modal')) dialog.close(); });
});
function check(el) {
var msg = el.checkValidity() ? '' : el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid');
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
if (!check(email)) { email.focus(); return; }
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
email.addEventListener('input', function () { if (email.classList.contains('is-invalid')) check(email); });
});
})();
// Opening and closing: buttons with data-open="id" open a dialog; showModal() when it has data-modal, show() otherwise.
// Esc closes both kinds, a backdrop click closes the modal one, and focus returns to the button that opened it.
// The forms are a front-end demo: nothing is sent. Errors are written into the element each input points to with
// aria-describedby; a valid submit swaps in the success state. Replace that branch with your fetch() call.
(function () {
document.querySelectorAll('[data-open]').forEach(function (btn) {
btn.addEventListener('click', function () {
var dialog = document.getElementById(btn.getAttribute('data-open'));
if (!dialog || dialog.open) return;
dialog.opener = btn;
if (dialog.hasAttribute('data-modal')) dialog.showModal(); else dialog.show();
if (!dialog.hasAttribute('data-modal')) { var first = dialog.querySelector('input,button:not([data-close])'); if (first) first.focus(); }
});
});
document.querySelectorAll('dialog.np').forEach(function (dialog) {
dialog.querySelectorAll('[data-close]').forEach(function (b) { b.addEventListener('click', function () { dialog.close(); }); });
dialog.addEventListener('close', function () { if (dialog.opener) dialog.opener.focus(); });
// Esc for non-modal dialogs (modal ones get it from the browser)
dialog.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !dialog.matches(':modal')) { e.preventDefault(); dialog.close(); } });
// light dismiss: a click that lands on the dialog element itself is a click on the backdrop
dialog.addEventListener('click', function (e) { if (e.target === dialog && dialog.matches(':modal')) dialog.close(); });
});
function check(el) {
var msg = el.checkValidity() ? '' : el.getAttribute(el.validity.valueMissing ? 'data-missing' : 'data-bad');
var err = document.getElementById(el.getAttribute('data-err'));
if (err) err.textContent = msg || '';
el.classList.toggle('is-invalid', !!msg);
if (msg) el.setAttribute('aria-invalid', 'true'); else el.removeAttribute('aria-invalid');
return !msg;
}
document.querySelectorAll('form[data-nf]').forEach(function (form) {
var done = document.getElementById(form.getAttribute('data-nf'));
var email = form.querySelector('input[type=email]');
form.noValidate = true;
form.addEventListener('submit', function (e) {
e.preventDefault();
if (!check(email)) { email.focus(); return; }
done.querySelectorAll('[data-email]').forEach(function (n) { n.textContent = email.value; });
form.hidden = true; done.hidden = false; done.focus();
});
email.addEventListener('input', function () { if (email.classList.contains('is-invalid')) check(email); });
});
})();
About this set
Three newsletter prompts built on the native dialog element, designed to be dismissible and honest. Centered popup opens with showModal(): an illustrated panel beside a magazine sign-up, a visible close button, a plain Not now link and a privacy line. Focus moves to the email field, the page behind becomes inert and stops scrolling, and Esc or a click on the backdrop closes it. Corner slide-in opens with show() in the bottom right, so the page stays usable while a reader decides whether to get the rest of an article series. Bottom bar is a full-width, non-modal strip for a weekly deals digest. All three open from a button, never on page load, and the copy avoids guilt-trip wording. A short vanilla script opens each dialog, adds Esc to the non-modal ones, returns focus to the button that opened it, and handles the form: inline error on a bad address, a confirmation on a good one. Nothing is sent. The demo page also draws an inert preview of each dialog so you can see the design without opening it.
What’s included
Native dialog: showModal() for the popup, show() for the slide-in and bar
Close button, Not now, Esc and backdrop click all dismiss
Focus returns to the opening button on close
Enter animations switch off under reduced motion
Bootstrap version uses .btn-close, .form-control and .btn inside the dialog, no Bootstrap JavaScript
Accessibility
Each dialog is named by its heading with aria-labelledby and its close button has aria-label="Close". The modal moves focus to the email field and traps it until closed; the non-modal dialogs move focus to their field and close on Esc. Focus returns to the trigger, errors are tied to the field with aria-describedby and aria-invalid, and every control shows a visible focus ring. Animations and transitions switch off under prefers-reduced-motion.
Customise it
Set --np-accent, --np-accent-dark and --np-ring on .np for the brand colour. The popup illustration is the .np-art gradient stack, and positions live on .np-corner and .np-bar. In Tailwind change the np-* colours and the np-pop, np-slide and np-rise animations in @theme.