Design an Age Verification Banner in Shopify (Accessible, No App)

Design an Age Verification Banner in Shopify (Accessible, No App)


If you sell age-restricted products (alcohol, vape, adult), you likely need an age verification step. Here’s a robust, accessible age gate you can drop into any Online Store 2.0 theme without an app.

Table of contents

Prerequisites

  • Basic theme editing access (Online Store 2.0)
  • Ability to edit theme.liquid and snippets/
  • Comfort with basic Liquid, CSS, and vanilla JS

Step 1: Create the snippet

Create snippets/age-verification.liquid with markup, scoped styles, and JS. This renders a modal (recommended) or can be adapted as a top banner.

{%- comment -%} snippets/age-verification.liquid {%- endcomment -%}
<div
  id="age-gate-overlay"
  class="age-gate-overlay"
  role="dialog"
  aria-modal="true"
  aria-labelledby="age-gate-title"
  aria-hidden="true"
>
  <div class="age-gate-dialog" tabindex="-1">
    <h2 id="age-gate-title" class="age-gate-heading">Are you over 21?</h2>
    <p class="age-gate-sub">You must be of legal drinking age to enter this site.</p>
    <div class="age-gate-actions">
      <button id="age-gate-yes" class="age-gate-btn age-gate-yes">Yes, I am</button>
      <button id="age-gate-no" class="age-gate-btn age-gate-no" aria-describedby="age-gate-alt">No</button>
    </div>
    <p id="age-gate-alt" class="age-gate-alt">If not, please exit this site.</p>
  </div>
</div>

<style>
  .age-gate-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.65);
    display: none;
    align-items: center;
    justify-content: center;
    z-index: 9999;
  }
  .age-gate-overlay[aria-hidden="false"] { display: flex; }
  .age-gate-dialog {
    width: min(92vw, 560px);
    background: #1D1042;
    border: 1px solid #1B152D;
    border-radius: 16px;
    padding: 24px;
    color: #fff;
    box-shadow: 0 10px 40px rgba(0,0,0,.5);
  }
  .age-gate-heading { font-size: 1.75rem; margin: 0 0 8px; }
  .age-gate-sub { margin: 0 0 16px; color: #c6c6d1; }
  .age-gate-actions { display: flex; gap: 12px; margin-top: 8px; }
  .age-gate-btn {
    flex: 1;
    padding: 12px 16px;
    border-radius: 10px;
    font-weight: 600;
    cursor: pointer;
    border: 1px solid #2a2350;
    transition: transform .15s ease, background .15s ease, border-color .15s ease;
  }
  .age-gate-yes { background: #146EF5; color: #fff; border-color: #146EF5; }
  .age-gate-yes:hover { transform: translateY(-1px); }
  .age-gate-no { background: transparent; color: #fff; }
  .age-gate-no:hover { border-color: #146EF5; }
  /* Focus ring */
  .age-gate-btn:focus { outline: 2px solid #93c5fd; outline-offset: 2px; }
  /* Optional: prevent background scroll when open */
  html.age-gate-open, body.age-gate-open { overflow: hidden; }
</style>

<script>
  (function() {
    var STORAGE_KEY = 'ageVerified';
    var DAYS = 30; // cookie lifespan fallback
    var overlay = document.getElementById('age-gate-overlay');
    var yesBtn = document.getElementById('age-gate-yes');
    var noBtn = document.getElementById('age-gate-no');
    var dialog = overlay && overlay.querySelector('.age-gate-dialog');
    var lastFocused;

    function setCookie(name, value, days) {
      var expires = '';
      if (days) {
        var date = new Date();
        date.setTime(date.getTime() + (days*24*60*60*1000));
        expires = '; expires=' + date.toUTCString();
      }
      document.cookie = name + '=' + (value || '') + expires + '; path=/; SameSite=Lax';
    }
    function getCookie(name) {
      var nameEQ = name + '=';
      var ca = document.cookie.split(';');
      for (var i = 0; i < ca.length; i++) {
        var c = ca[i];
        while (c.charAt(0) === ' ') c = c.substring(1, c.length);
        if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
      }
      return null;
    }
    function setVerified() {
      try { localStorage.setItem(STORAGE_KEY, 'true'); } catch(e) {}
      setCookie(STORAGE_KEY, 'true', DAYS);
    }
    function isVerified() {
      try { if (localStorage.getItem(STORAGE_KEY) === 'true') return true; } catch(e) {}
      return getCookie(STORAGE_KEY) === 'true';
    }

    function openGate() {
      if (!overlay) return;
      lastFocused = document.activeElement;
      overlay.setAttribute('aria-hidden', 'false');
      document.documentElement.classList.add('age-gate-open');
      document.body.classList.add('age-gate-open');
      setTimeout(function() { dialog && dialog.focus(); }, 0);
    }
    function closeGate() {
      overlay.setAttribute('aria-hidden', 'true');
      document.documentElement.classList.remove('age-gate-open');
      document.body.classList.remove('age-gate-open');
      if (lastFocused && lastFocused.focus) lastFocused.focus();
    }
    function trapFocus(e) {
      if (!overlay || overlay.getAttribute('aria-hidden') === 'true') return;
      if (!dialog || !dialog.contains(e.target)) {
        e.stopPropagation();
        dialog && dialog.focus();
      }
    }

    function init() {
      if (!overlay) return;
      if (isVerified()) return; // already verified

      // Optionally skip certain pages
      var pageTypeEl = document.getElementById('shopify-digital-wallet'); // marker present on product pages; not reliable for all
      // You can also gate only specific templates by adding conditionals around the render in theme.liquid

      overlay.setAttribute('aria-hidden', 'true');
      openGate();

      yesBtn && yesBtn.addEventListener('click', function() {
        setVerified();
        closeGate();
      });
      noBtn && noBtn.addEventListener('click', function() {
        window.location.href = 'https://www.google.com';
      });
      document.addEventListener('focus', trapFocus, true);
      document.addEventListener('keydown', function(e) {
        if (e.key === 'Escape') {
          // Do not allow closing without verification. If you want to allow closing, uncomment:
          // closeGate();
        }
      });
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', init);
    } else { init(); }
  })();
</script>

Notes

  • Uses both localStorage and a cookie for resilience (e.g., Safari ITP/private). Adjust DAYS as needed.
  • The dialog is accessible: role="dialog", aria-modal, and focus is trapped inside until verified.
  • Customize copy/colors to your brand. You can convert this to a top banner by changing overlay CSS to position: sticky; top: 0; and removing the modal behaviors.

Step 2: Include it globally

Add this near the end of layout/theme.liquid, just before </body>. Use a conditional to skip accounts/cart/checkout if desired.

{%- comment -%} theme.liquid {%- endcomment -%}
{%- unless request.page_type == 'cart' or request.page_type == 'account' or request.page_type == 'login' or request.page_type == 'register' or request.page_type == 'checkout' -%}
  {%- render 'age-verification' -%}
{%- endunless -%}

If you only need the gate on specific templates (e.g., only product pages), narrow it:

{%- if request.page_type == 'product' -%}
  {%- render 'age-verification' -%}
{%- endif -%}

Step 3: Brand it quickly

You can overwrite the snippet’s styles in your theme CSS file. Example (Dawn-like tokens):

/* assets/base.css or theme.css */
.age-gate-dialog { background: #0E0820; border-color: #1B152D; }
.age-gate-yes { background: #146EF5; border-color: #146EF5; }
.age-gate-no { color: #c6c6d1; }

QA checklist

  • Keyboard: Tab cycles inside; no focus escapes. Screenreader announces dialog and title.
  • Persistence: After clicking “Yes”, refresh — it should not show again for 30 days.
  • Cart/checkout/account pages are excluded (if you used the unless).
  • Mobile: No background scroll; buttons accessible.

Variations

  • Add a birthdate input and validate year >= required age (store only a boolean, not the date, to avoid PII risk).
  • Gate only for certain countries with a GeoIP header (via an app proxy or CDN rule) and conditionally render the snippet.
  • Show a slim banner instead of a modal; on click “I’m 21+” set persistence and hide.

Troubleshooting

  • Gate shows again on every page load: ensure cookies aren’t blocked and localStorage isn’t disabled; verify the STORAGE_KEY matches in both set/get.
  • Dialog never appears: confirm the snippet is rendered on your template and the overlay’s aria-hidden toggles to false on load.
  • Shopify Online Store editor preview quirks: sometimes scripts won’t run in preview; test on a live theme preview URL.

That’s it — ship it without an app, keep it fast, and stay compliant.