Forms & Lead Capture

Free Newsletter Popup Widget

A timed or scroll-triggered modal that captures email signups.

A lightweight modal that invites visitors to join your mailing list after a set delay or once they scroll partway down the page. It shows once per session and posts each signup straight to your own form endpoint. Install it with a single script tag on any site.

Live demo

Embed this widget

Paste either snippet into your page — a Custom HTML block on WordPress, Shopify, or Squarespace works too. Both snippets update live as you customize, so what you copy is exactly what you configured.

Hosted (recommended)html
<script src="https://hayneslab.dev/widgets/newsletter-popup.js" data-hl-action="https://hayneslab.dev/api/newsletter-demo" data-hl-title="Get the good stuff first" data-hl-message="Join 2,000+ readers getting one practical web tip every week. Free forever." data-hl-delay="4000" data-hl-scroll="40" async></script>
<!-- Free Newsletter Popup widget by haynes lab — https://hayneslab.dev/tools/widgets/newsletter-popup -->
Inline (self-contained)html
<!-- Free Newsletter Popup widget by haynes lab — https://hayneslab.dev/tools/widgets/newsletter-popup -->
<script data-hl-action="https://hayneslab.dev/api/newsletter-demo" data-hl-title="Get the good stuff first" data-hl-message="Join 2,000+ readers getting one practical web tip every week. Free forever." data-hl-delay="4000" data-hl-scroll="40">
/*!
 * haynes lab — Newsletter Popup widget (free)
 * Docs & embed code: https://hayneslab.dev/tools/widgets/newsletter-popup
 */
(function () {
  'use strict';

  var PREFIX = 'hlw-newsletter-popup';
  var STYLE_ID = 'hlw-newsletter-popup-styles';

  function findScript() {
    return document.currentScript ||
      document.querySelector('script[src*="widgets/newsletter-popup.js"]');
  }

  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    var css =
      '.' + PREFIX + ', .' + PREFIX + ' * { box-sizing: border-box; }' +
      '.' + PREFIX + ' { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;' +
      ' font-size: 16px; line-height: 1.5; color: #1f2937; }' +
      '.' + PREFIX + '__overlay { position: fixed; inset: 0; z-index: 99999; display: flex;' +
      ' align-items: center; justify-content: center; padding: 20px;' +
      ' background: rgba(17, 24, 39, 0.6); }' +
      '.' + PREFIX + '__overlay--staged { position: absolute; z-index: 10; }' +
      '.' + PREFIX + '__dialog { position: relative; width: 100%; max-width: 420px; padding: 36px 32px 44px;' +
      ' background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; text-align: center;' +
      ' box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25); }' +
      '.' + PREFIX + '__close { position: absolute; top: 10px; right: 10px; width: 32px; height: 32px;' +
      ' border: 0; border-radius: 50%; background: transparent; color: #9ca3af; font-size: 20px;' +
      ' line-height: 1; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; }' +
      '.' + PREFIX + '__close:hover { background: #f3f4f6; color: #4b5563; }' +
      '.' + PREFIX + '__close:focus-visible { outline: 2px solid var(--hlw-accent); outline-offset: 2px; }' +
      '.' + PREFIX + '__title { margin: 0 0 10px; font-size: 22px; font-weight: 700; color: #111827; }' +
      '.' + PREFIX + '__message { margin: 0 0 20px; font-size: 15px; color: #4b5563; }' +
      '.' + PREFIX + '__form { display: flex; gap: 8px; }' +
      '.' + PREFIX + '__input { flex: 1 1 auto; min-width: 0; padding: 11px 14px; border: 1px solid #d1d5db;' +
      ' border-radius: 8px; font: inherit; font-size: 15px; color: #111827; background: #ffffff; }' +
      '.' + PREFIX + '__input:focus { outline: 2px solid var(--hlw-accent); outline-offset: 1px; border-color: var(--hlw-accent); }' +
      '.' + PREFIX + '__submit { flex: 0 0 auto; padding: 11px 20px; border: 0; border-radius: 8px;' +
      ' background: var(--hlw-accent); color: #ffffff; font: inherit; font-size: 15px; font-weight: 600; cursor: pointer; }' +
      '.' + PREFIX + '__submit:hover { filter: brightness(0.92); }' +
      '.' + PREFIX + '__submit:focus-visible { outline: 2px solid var(--hlw-accent); outline-offset: 2px; }' +
      '.' + PREFIX + '__submit[disabled] { opacity: 0.6; cursor: default; }' +
      '.' + PREFIX + '__status { margin: 12px 0 0; font-size: 14px; color: #4b5563; }' +
      '.' + PREFIX + '__status--error { color: #dc2626; }' +
      '.' + PREFIX + '__preview { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px;' +
      ' border: 1px dashed #d1d5db; border-radius: 8px; background: #f9fafb; color: #4b5563;' +
      ' font-size: 13px; cursor: pointer; }' +
      '.' + PREFIX + '__preview:hover { border-color: var(--hlw-accent); color: var(--hlw-accent); }' +
      '.' + PREFIX + '__preview:focus-visible { outline: 2px solid var(--hlw-accent); outline-offset: 2px; }' +
      '.' + PREFIX + '__credit { position: absolute; right: 10px; bottom: 8px; font-size: 11px;' +
      ' display: inline-flex; align-items: center; gap: 4px; color: #9ca3af; text-decoration: none; }' +
      '.' + PREFIX + '__credit svg { display: block; }' +
      '.' + PREFIX + '__credit:hover { color: #6b7280; text-decoration: underline; }' +
      '@media (max-width: 560px) { .' + PREFIX + '__dialog { padding: 28px 20px 42px; }' +
      ' .' + PREFIX + '__title { font-size: 19px; }' +
      ' .' + PREFIX + '__form { flex-direction: column; } }';
    var style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;
    document.head.appendChild(style);
  }

  function el(tag, className, text) {
    var node = document.createElement(tag);
    if (className) node.className = className;
    if (text != null) node.textContent = text;
    return node;
  }

  function logoSvg() {
    var svgNS = 'http://www.w3.org/2000/svg';
    var svg = document.createElementNS(svgNS, 'svg');
    svg.setAttribute('viewBox', '0 0 32 32');
    svg.setAttribute('width', '13');
    svg.setAttribute('height', '13');
    svg.setAttribute('aria-hidden', 'true');
    svg.setAttribute('focusable', 'false');
    function circle(cx, cy, r, attrs) {
      var c = document.createElementNS(svgNS, 'circle');
      c.setAttribute('cx', cx);
      c.setAttribute('cy', cy);
      c.setAttribute('r', r);
      for (var key in attrs) c.setAttribute(key, attrs[key]);
      svg.appendChild(c);
    }
    circle(16, 16, 16, { fill: '#0f0a0a' });
    circle(16, 16, 12, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.3 });
    circle(16, 16, 9, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1, opacity: 0.5 });
    circle(16, 16, 6, { fill: 'none', stroke: '#fff5f5', 'stroke-width': 1.2, opacity: 0.9 });
    circle(16, 16, 3, { fill: '#06b6d4' });
    circle(24.5, 7.5, 2, { fill: '#f97316' });
    circle(7, 16, 1.8, { fill: '#f97316' });
    circle(20, 20, 1.5, { fill: '#f97316' });
    return svg;
  }

  function build(script) {
    var action = script.getAttribute('data-hl-action');
    if (!action) {
      console.warn('[haynes lab newsletter-popup] Missing required data-hl-action attribute (form POST endpoint). Nothing rendered.');
      return;
    }
    var title = script.getAttribute('data-hl-title') || 'Join our newsletter';
    var message = script.getAttribute('data-hl-message') ||
      'Get tips, updates, and offers delivered to your inbox. No spam, unsubscribe anytime.';
    var delay = parseInt(script.getAttribute('data-hl-delay'), 10);
    if (isNaN(delay) || delay < 0) delay = 5000;
    var scroll = parseInt(script.getAttribute('data-hl-scroll'), 10);
    if (isNaN(scroll) || scroll < 1 || scroll > 100) scroll = 0; // 0 = scroll trigger off
    var accent = script.getAttribute('data-hl-accent') || '#f97316';

    injectStyles();

    var stage = script.closest ? script.closest('.widget-demo__stage') : null;
    var storageKey = 'hlw-newsletter-popup-shown:' + action;

    var overlay = el('div', PREFIX + ' ' + PREFIX + '__overlay' + (stage ? ' ' + PREFIX + '__overlay--staged' : ''));
    overlay.style.setProperty('--hlw-accent', accent);
    overlay.setAttribute('hidden', '');
    overlay.style.display = 'none';

    var dialog = el('div', PREFIX + '__dialog');
    dialog.setAttribute('role', 'dialog');
    dialog.setAttribute('aria-modal', 'true');
    dialog.setAttribute('aria-label', title);

    var close = el('button', PREFIX + '__close', '×');
    close.type = 'button';
    close.setAttribute('aria-label', 'Close popup');

    dialog.appendChild(close);
    dialog.appendChild(el('h2', PREFIX + '__title', title));
    dialog.appendChild(el('p', PREFIX + '__message', message));

    var form = el('form', PREFIX + '__form');
    var input = el('input', PREFIX + '__input');
    input.type = 'email';
    input.name = 'email';
    input.required = true;
    input.placeholder = '[email protected]';
    input.setAttribute('aria-label', 'Email address');
    var submit = el('button', PREFIX + '__submit', 'Subscribe');
    submit.type = 'submit';
    form.appendChild(input);
    form.appendChild(submit);
    dialog.appendChild(form);

    var status = el('p', PREFIX + '__status');
    status.setAttribute('aria-live', 'polite');

    var credit = el('a', PREFIX + '__credit');
    credit.appendChild(logoSvg());
    credit.appendChild(document.createTextNode('Powered by haynes lab'));
    credit.href = 'https://hayneslab.dev/tools/widgets/newsletter-popup';
    credit.target = '_blank';
    credit.rel = 'noopener';
    dialog.appendChild(credit);

    overlay.appendChild(dialog);

    function markShown() {
      try { sessionStorage.setItem(storageKey, '1'); } catch (e) { /* storage unavailable */ }
    }
    function hide() {
      overlay.style.display = 'none';
      overlay.setAttribute('hidden', '');
      document.removeEventListener('keydown', onKeydown);
    }
    function onKeydown(e) {
      if (e.key === 'Escape') hide();
    }
    function show(mark) {
      if (mark) markShown();
      overlay.removeAttribute('hidden');
      overlay.style.display = 'flex';
      document.addEventListener('keydown', onKeydown);
      input.focus();
    }

    form.addEventListener('submit', function (e) {
      e.preventDefault();
      submit.disabled = true;
      status.textContent = '';
      status.className = PREFIX + '__status';
      fetch(action, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
        body: JSON.stringify({ email: input.value })
      }).then(function (res) {
        if (!res.ok) throw new Error('HTTP ' + res.status);
        form.style.display = 'none';
        status.textContent = 'Thanks for subscribing!';
        dialog.appendChild(status);
        markShown();
        setTimeout(hide, 2500);
      }).catch(function () {
        status.textContent = 'Something went wrong. Please try again.';
        status.className = PREFIX + '__status ' + PREFIX + '__status--error';
        if (!status.parentNode) dialog.appendChild(status);
        submit.disabled = false;
      });
    });

    close.addEventListener('click', function () { markShown(); hide(); });
    overlay.addEventListener('click', function (e) {
      if (e.target === overlay) { markShown(); hide(); }
    });

    if (stage) {
      // Landing-page demo: never auto-trigger, offer a preview button instead.
      stage.appendChild(overlay);
      var preview = el('button', PREFIX + ' ' + PREFIX + '__preview', 'Preview popup');
      preview.type = 'button';
      preview.style.setProperty('--hlw-accent', accent);
      preview.addEventListener('click', function () { show(false); });
      stage.appendChild(preview);
      return;
    }

    // Real page: trigger once per session, on data-hl-delay or scroll depth —
    // whichever comes first.
    document.body.appendChild(overlay);
    var alreadyShown = false;
    try { alreadyShown = sessionStorage.getItem(storageKey) === '1'; } catch (e) { /* ignore */ }
    if (alreadyShown) return;

    var fired = false;
    function trigger() {
      if (fired) return;
      fired = true;
      window.removeEventListener('scroll', onScroll);
      show(true);
    }

    function onScroll() {
      var doc = document.documentElement;
      var max = doc.scrollHeight - window.innerHeight;
      if (max <= 0) return;
      if ((window.pageYOffset || doc.scrollTop) / max * 100 >= scroll) trigger();
    }

    setTimeout(trigger, delay);
    if (scroll) window.addEventListener('scroll', onScroll, { passive: true });
  }

  function init() {
    var script = findScript();
    if (!script) {
      console.warn('[haynes lab newsletter-popup] Could not locate its own script tag. Nothing rendered.');
      return;
    }
    build(script);
  }

  if (document.body) {
    init();
  } else {
    document.addEventListener('DOMContentLoaded', init);
  }
})();

</script>

Configuration

Every option is an attribute on the script tag — the same attributes the customizer above exposes. All attributes are optional unless marked required — defaults apply when omitted.

AttributeDefaultDescription
data-hl-action(required)URL the signup is POSTed to as JSON ({ "email": ... }). Use your email platform's form endpoint.
data-hl-title"Join our newsletter"The heading shown at the top of the popup.
data-hl-message"Get tips, updates, and offers delivered to your inbox. No spam, unsubscribe anytime."The body text explaining why visitors should subscribe.
data-hl-delay"5000"Milliseconds after page load before the popup appears.
data-hl-scroll(none)Optional page-scroll percentage that also triggers the popup — whichever of delay or scroll happens first shows it.
data-hl-accent"#f97316"Color of the subscribe button and focus outlines.

Frequently asked questions

How do I install the newsletter popup?

Paste the script tag anywhere before the closing </body> tag on your site. Set data-hl-action to your form endpoint, and the popup appears after the data-hl-delay time (or the data-hl-scroll depth, if set) as a centered modal.

Where do the email signups go?

Each signup is sent as a JSON POST ({ "email": "..." }) to the URL in data-hl-action. Point it at your email platform's form endpoint — Mailchimp, ConvertKit, Formspree, or your own API all work.

How often does the popup show to the same visitor?

Once per browser session. After it appears (or the visitor closes it), a sessionStorage flag keeps it hidden until the browser is closed and reopened.

Does it work on WordPress, Shopify, or Squarespace?

Yes — it is a single vanilla JavaScript file with no dependencies. Paste the snippet into any platform that lets you add custom scripts or edit your theme, including WordPress, Shopify, Squarespace, Wix, and plain HTML sites.

More free widgets

Need a custom widget?

We build custom widgets, integrations, and whole products. Tell us what your site needs and we'll scope it.

Get in Touch