Even in the era of the modern “Small Web”, static sites still need a way to convert readers into a loyal audience. No one one visits a clean, static site hosted on ZenDenPen hoping to be blinded by a massive, screen-blocking “SUBSCRIBE NOW” modal or tracked by third-party scripts.
This guide delivers a complete, privacy-preserving newsletter signup solution that respects your readers’ intelligence. Itβs entirely self-contained, completely non-obtrusive, and strategically engineered to catch subscribers exactly when they are most engaged, without sharing an ounce of user data with the outside world.
The ultimate benefit? This is pure HTML, CSS, and JavaScript. No external frameworks, no bloated libraries, no trackers, and absolutely zero build or compile steps. If you can copy and paste a snippet into your existing codebase (hugo, html, etc), you can give your static site a sovereign growth engine today.
Here’s how it works :
- The popup is shown when the visitor scrolls for a minimum of 55% of an articles/page so you don’t annoy people giving them a full page subscription form
- If the visitor clicks on X it’s hidden untill the user scrolls again on a 2nd or 3rd page when it’s shown again so it keeps track of pageview count but does not share this information with any system it’s just for UX.
- If the visitor clicks on subscribe then the popup is hidden for 30+ days
- If the user doesn’t have javascript enabled then at the bottom of the page they’ll see the subscribe functionality (you may opt to move it anywhere you like)
- No cookies stored, it uses session sotrage and local storage to keep track of state
I’m currently using on this Hugo website currently hosted on [https://zendenpen.com]
This guide presumes you already have a newsletter self-hosted newsletter. I will be using ListMonk in this example for now, but plan to add this functionality in Zenix . If you DON’t have a self hosted newsletter but want one I offer a service to install such software on a VPS for you;)
Newsletter popup Design
First we need a design, I will be using picocss and inline styles for easier flow.
vim _layouts/default/newsletter.html
<!-- Container, should be at end -->
<article id="zen-subscribe-container" class="zen-static-form" style="margin: 40px auto; max-width: 1024px; padding: 20px; background: ; border: 1px solid #333; border-radius: 8px;">
<div style="position: relative; text-align: center;">
<h4>Andrei PrivacyZen Newsletter</h4>
<p id="zen-form-text" style="margin: 0 0 12px 0; font-size: 1rem; color: var(--pico-color);">
Liked the article? join Andrei's PrivacyZen Newsletter. Topics include wellbeing, privacy, life, programming, and cybersecurity.
</p>
<form action="https://newsletter.PRIVACYZEN.eu/subscription/form" method="POST" onsubmit="handleZenSubmit()" style="display: flex; gap: 8px; flex-wrap: wrap;">
<style>.bct { display: none;}</style>
<input type="input" class="bct" name="nonce" />
<input id="27b17" type="hidden" name="l" checked value="c455dafd-9347-49b8-afb4-18a1ecc0d80c" />
<fieldset class="grid" >
<input type="text" name="name" class="input form-control" placeholder="Name (optional)" />
<input type="email" name="email" placeholder="your@email.com" required >
<altcha-widget challengeurl="https://newsletter.privacyzen.eu/api/captcha/altcha"></altcha-widget>
<script type="module" src="https://newsletter.privacyzen.eu/public/static/altcha.umd.js" async defer></script>
<button type="submit" style="">Subscribe</button>
</fieldset>
</form>
<!-- Close button,shown only with JS -->
<button id="zen-close-btn" onclick="handleZenDismiss()" style="display: none; position: absolute; top: -5px; right: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 14px;">β</button>
</div>
</article>
THe above form is self contained, looks OK-ish and
Security Setting
THe bellow settings are required only if you link to the altcha javascript functionality on listmonk to catch bots, if you want to get spam or rely on another newsletter provider review their specific settings.
CSP Content Security Policy
The above newsletter form makes use of a nonce to catch bots but I’ve also enabled altcha in Listmonk. However, since I host it on a separate subdomain/domain (and so will you if you use any newsletter). It’s important to enable content security policy otherwise the altcha javacript won’t work. This is to ensure that your page doesn’t allow external scripts/css which is not allowed. MOst browsers block this by default
Here’s a simple meta tag to add in your
<meta http-equiv="Content-Security-Policy" content="default-src 'self' fonts.googleapis.com fonts.gstatic.com https://comments.subl.im; script-src 'self' 'unsafe-inline' https://comments.subl.im https://newsletter.privacyzen.eu; child-src 'self'; object-src 'self'; frame-src https://comments.subl.im; frame-ancestors 'self' https://.subl.im; style-src 'self' 'unsafe-inline';">
The most important part which is interesting is the script-src 'self' 'unsafe-inline' https://comments.subl.im https://newsletter.privacyzen.eu; part
Enabling CORS in caddy
If you own the newsletter domain you can easily enable a header such as Access-Control-Allow-Origin: https://example.com
Read more on CORS
ListMonk version 6+ should allow you to have a setting in the admin panel for CORS.
for previous versions such as 5.X you can use
If you use external newsletter providers they might have you covered, just check their documentation.
sudo vim /etc/caddy/Caddyfile
....
(cors) {
@cors_preflight method OPTIONS
@cors header Origin {args.0}
handle @cors_preflight {
header Access-Control-Allow-Origin "{args.0}"
header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE"
header Access-Control-Allow-Headers "Content-Type"
header Access-Control-Max-Age "3600"
respond "" 204
}
handle @cors {
header Access-Control-Allow-Origin "{args.0}"
header Access-Control-Expose-Headers "Link"
}
}
newsletter.privacyzen.eu {
encode {
zstd
gzip
}
import cors https://andreiclinciu.net
reverse_proxy localhost:9999
}
...
The JavaScript
The javascript for the popup is quite simple and self explainatory. It doesn’t require any framework or external library. You may add it to _layouts/default/newsletter.html or to a separate javascript file:).
<script>
(function() {
var box = document.getElementById('zen-subscribe-container');
var fieldset = document.querySelector('#zen-subscribe-container fieldset');
var closeBtn = document.getElementById('zen-close-btn');
var now = new Date().getTime();
// increment visits in session storage
var pageViews = parseInt(sessionStorage.getItem('zen_page_views') || '0') + 1;
var dismissStreak = parseInt(localStorage.getItem('zen_dismiss_streak') || '0');
sessionStorage.setItem('zen_page_views', pageViews);
// 1. Verify hiding with timestamp
var hideUntil = localStorage.getItem('zen_hide_until');
if (hideUntil && now < parseInt(hideUntil) ) {
if (!(dismissStreak < 2 && pageViews >= 3)) {
box.style.display = 'none';
return; // Grace period, do nothing
}
}
// If grace period passed, clean up old state
if (hideUntil && now >= parseInt(hideUntil)) {
localStorage.removeItem('zen_hide_until');
}
// 2. JS is active, transform static form in discreet slide in
fieldset.classList.remove("grid")
box.style.position = 'fixed';
box.style.bottom = '20px';
box.style.right = '20px';
box.style.zIndex = '1000';
box.style.maxWidth = '480px';
box.style.margin = '0';
box.style.boxShadow = '0 4px 16px rgba(0,0,0,0.6)';
box.style.display = 'none'; // Initally hidden
closeBtn.style.display = 'block'; // Only show X button if JS is on:)
console.log("Whow, should I show?")
// 4. Scrill trigger
window.addEventListener('scroll', function() {
var totalHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
var scrollPosition = window.scrollY;
if (totalHeight > 0 && (scrollPosition / totalHeight) > 0.55) {
if (dismissStreak === 1 && pageViews < 3) {
return;
}
box.style.display = 'block';
}
});
})();
// User clicks subscribe, don't show subscribe again for 30 days
function handleZenSubmit() {
var expireTime = new Date().getTime() + (30 * 24 * 60 * 60 * 1000);
localStorage.setItem('zen_hide_until', expireTime);
localStorage.removeItem('zen_dismiss_streak');
}
//User clicks dismiss, don't show it for one day, if clicked dismiss again (thus still reading)hide for 3 days
function handleZenDismiss() {
var box = document.getElementById('zen-subscribe-container');
box.style.display = 'none';
var now = new Date().getTime();
var dismissStreak = parseInt(localStorage.getItem('zen_dismiss_streak') || '0') + 1;
localStorage.setItem('zen_dismiss_streak', dismissStreak);
var cooldownDays = 1;
if (dismissStreak >= 2) {
cooldownDays = 3;
}
var expireTime = now + (cooldownDays * 24 * 60 * 60 * 1000);
localStorage.setItem('zen_hide_until', expireTime);
}
</script>
That’;s it. Self Contained & perfect to use on static sites such as those hosted on ZenDenPen
If you liked this article, please subscribe to the newsletter;). I’d say leave a comment but I removed them till I finish Zenix.
For Zenix I plan to add a subscribe( and commenting) functionality as a “built in” system.
Need something like this?
I build high-performance Go-based software and websites which I can host for you on a $5 Linux VPS. No intermediate agency layers, direct developer execution.
Discuss an Integration →