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
- Step 1: Create the snippet
- Step 2: Include it globally
- Step 3: Brand it quickly
- Notes
- QA checklist
- Variations
- Troubleshooting
Prerequisites
- Basic theme editing access (Online Store 2.0)
- Ability to edit
theme.liquidandsnippets/ - 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
localStorageand a cookie for resilience (e.g., Safari ITP/private). AdjustDAYSas 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
localStorageisn’t disabled; verify theSTORAGE_KEYmatches in both set/get. - Dialog never appears: confirm the snippet is rendered on your template and the overlay’s
aria-hiddentoggles tofalseon 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.