If you search for a cookie consent banner HTML snippet, you mostly find hosted consent platforms that inject 80 to 150 KB of JavaScript, add extra DNS lookups and sometimes break your Largest Contentful Paint. For a lot of sites, that is overkill. A compliant banner is basically three things: some markup, a stored decision, and a script loader that only fires after the user says yes.
This tutorial shows how to build exactly that: a self-hosted cookie consent banner in plain HTML, CSS and vanilla JavaScript, around 2 KB, that stores the user choice in localStorage and conditionally loads analytics scripts only after consent. No dependencies, no external requests, full control over the markup and the styling.
What a cookie consent banner actually has to do
Before writing code, it helps to know which behaviours are non negotiable under the GDPR and the ePrivacy Directive. Most banners fail not because of their design, but because they fire tracking scripts before the click. There is a practical rundown of it online.
| Requirement | What it means in code |
|---|---|
| Prior consent | No analytics, ads or social scripts in the initial HTML. They are injected after the accept click. |
| Freely given | A reject option with the same visual weight as accept, on the first layer. |
| Specific and informed | Named categories plus a link to your cookie or privacy policy. |
| No pre-ticked boxes | Optional categories default to false in your state object. |
| Withdrawable | A permanent Cookie settings link in the footer that reopens the banner. |
| Provable | Store a timestamp and a version with the decision, and optionally log it server side. |
Strictly necessary cookies, such as a login session or a CSRF token, do not need consent. That is why the banner below never blocks your own essential functionality.

What we are building
- A fixed bottom banner with Accept all, Reject non-essential and an optional Customize step
- The decision stored in
localStorageas JSON, with a version and a timestamp - Google Analytics 4 (or any other tag) injected only when
analyticsis granted - A reusable
cc:consentDOM event so other scripts can react - Zero external requests, zero layout shift, no render-blocking JavaScript
Step 1: the HTML markup
Put this just before the closing </body> tag. It is a dialog role, hidden by default, so nothing flashes if consent was already given. cookiesaur.com published something useful on the subject.
<div id='cc-banner' class='cc-banner' role='dialog' aria-live='polite'
aria-labelledby='cc-title' aria-describedby='cc-desc' hidden>
<div class='cc-inner'>
<div class='cc-text'>
<h2 id='cc-title' class='cc-title'>We use cookies</h2>
<p id='cc-desc' class='cc-desc'>
Essential cookies keep this site running. With your consent we also use
analytics cookies to measure how pages are used. You can change your
mind at any time.
<a href='/privacy-policy/' class='cc-link'>Read our privacy policy</a>
</p>
</div>
<div class='cc-actions'>
<button type='button' class='cc-btn cc-btn-ghost' data-cc='reject'>Reject non-essential</button>
<button type='button' class='cc-btn cc-btn-solid' data-cc='accept'>Accept all</button>
</div>
</div>
</div>
Two details that matter:
- The buttons are real
<button>elements, not links or divs, so keyboard and screen reader users get them for free. - Reject and accept share the same
cc-btnbase class. Only the fill changes, which keeps the two choices equally prominent.
Step 2: the CSS
Inline this in the <head> or add it to your main stylesheet. Because the banner is position: fixed, it never pushes content around, so it cannot hurt your Cumulative Layout Shift score. The piece Cookie Banner: Legal Requirement or Best Practice makes a good next read.
.cc-banner{position:fixed;inset:auto 0 0 0;z-index:9999;background:#111;color:#f5f5f5;
font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
box-shadow:0 -2px 16px rgba(0,0,0,.35)}
.cc-banner[hidden]{display:none}
.cc-inner{max-width:1100px;margin:0 auto;padding:18px 20px;display:flex;gap:20px;
align-items:center;flex-wrap:wrap}
.cc-title{margin:0 0 4px;font-size:17px;font-weight:700}
.cc-desc{margin:0;font-size:14px;color:#d8d8d8}
.cc-link{color:#7fd1ff;text-decoration:underline}
.cc-actions{display:flex;gap:10px;margin-left:auto;flex-wrap:wrap}
.cc-btn{cursor:pointer;border-radius:6px;padding:11px 18px;font-size:14px;font-weight:600;
border:1px solid #f5f5f5;background:transparent;color:#f5f5f5}
.cc-btn-solid{background:#f5f5f5;color:#111}
.cc-btn:focus-visible{outline:3px solid #7fd1ff;outline-offset:2px}
@media (max-width:640px){
.cc-inner{padding:16px;gap:14px}
.cc-actions{margin-left:0;width:100%}
.cc-btn{flex:1 1 auto}
}

Step 3: the JavaScript with localStorage
This is the core of the whole cookie consent banner. The state object is intentionally boring: a version, a timestamp and a categories map. Keeping the version lets you re-ask for consent when you add a new vendor.
(function () {
var KEY = 'cc_consent_v1';
var TTL_DAYS = 180; // re-ask after 6 months
var GA_ID = 'G-XXXXXXXXXX'; // replace with your own
var banner = document.getElementById('cc-banner');
function readConsent() {
try {
var raw = window.localStorage.getItem(KEY);
if (!raw) return null;
var data = JSON.parse(raw);
if (!data || !data.ts || !data.categories) return null;
var ageDays = (Date.now() - data.ts) / 86400000;
if (ageDays > TTL_DAYS) return null;
return data;
} catch (e) { return null; }
}
function saveConsent(categories) {
var data = { v: 1, ts: Date.now(), categories: categories };
try { window.localStorage.setItem(KEY, JSON.stringify(data)); } catch (e) {}
return data;
}
function loadAnalytics() {
if (window.__ccAnalytics) return;
window.__ccAnalytics = true;
var s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + GA_ID;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
window.gtag = function () { window.dataLayer.push(arguments); };
window.gtag('js', new Date());
window.gtag('config', GA_ID, { anonymize_ip: true });
}
function applyConsent(data) {
if (data.categories.analytics) loadAnalytics();
document.dispatchEvent(new CustomEvent('cc:consent', { detail: data }));
}
function hideBanner() { banner.hidden = true; }
function showBanner() {
banner.hidden = false;
var first = banner.querySelector('[data-cc=\"accept\"]');
if (first) first.focus({ preventScroll: true });
}
function decide(categories) {
applyConsent(saveConsent(categories));
hideBanner();
}
banner.addEventListener('click', function (e) {
var action = e.target.getAttribute && e.target.getAttribute('data-cc');
if (action === 'accept') decide({ essential: true, analytics: true });
if (action === 'reject') decide({ essential: true, analytics: false });
});
// Footer link: <button type='button' data-cc-open>Cookie settings</button>
document.addEventListener('click', function (e) {
var opener = e.target.closest && e.target.closest('[data-cc-open]');
if (opener) { e.preventDefault(); showBanner(); }
});
var stored = readConsent();
if (stored) { applyConsent(stored); } else { showBanner(); }
})();
Load it with <script src='/js/cookie-consent.js' defer></script>. Because it is deferred and dependency free, it costs you nothing on first paint.
Why localStorage instead of a cookie?
| Storage | Pros | Cons |
|---|---|---|
localStorage |
No bytes sent on every request, easy JSON, simple to inspect and clear | Not readable server side, per origin only |
| Cookie | Readable in your backend, works across subdomains | Adds weight to every HTTP request, needs escaping and expiry handling |
Use localStorage when all your tags are client side, which is the common case. Switch to a first-party cookie if your server needs to know the decision before rendering, for example to decide whether to output a tracking pixel in the HTML. Storing the consent choice itself is considered strictly necessary, so it does not require consent.
Step 4: block the scripts before consent (the part most people get wrong)
A banner that looks compliant but still has gtag.js in the <head> is not compliant. Remove every optional tag from your templates and let the loader inject them.
Third-party embeds
Iframes drop cookies too. Replace them with a placeholder and swap it in on consent:
<div class='cc-embed' data-cc-src='https://www.youtube-nocookie.com/embed/VIDEO_ID'>
<p>This video is loaded only after you accept marketing cookies.</p>
</div>
<script>
document.addEventListener('cc:consent', function (e) {
if (!e.detail.categories.analytics) return; // or a marketing category
document.querySelectorAll('[data-cc-src]').forEach(function (box) {
var f = document.createElement('iframe');
f.src = box.getAttribute('data-cc-src');
f.width = '560'; f.height = '315'; f.loading = 'lazy';
f.setAttribute('allowfullscreen', '');
f.setAttribute('title', 'Embedded video');
box.replaceChildren(f);
});
});
</script>
Bonus: this pattern is also a real performance win, since heavy embeds no longer compete for bandwidth during page load.
Step 5: let users change their mind
Add this anywhere in your footer. The delegated click handler in step 3 already listens for it:
<button type='button' class='cc-footer-link' data-cc-open>Cookie settings</button>
If a user rejects, then reopens and accepts, the loader runs immediately thanks to the __ccAnalytics guard, so nothing is loaded twice. To fully withdraw analytics consent, the cleanest approach is to save the new state and reload the page, since scripts already in memory cannot be untangled reliably:
if (action === 'reject' && window.__ccAnalytics) {
saveConsent({ essential: true, analytics: false });
window.location.reload();
return;
}
You should also delete the cookies those scripts created, for example the GA _ga and _ga_* cookies, by setting them with an expiry in the past on your root domain.

Step 6 (optional): granular categories
If you run analytics and advertising or A/B testing, a single accept button is not specific enough. Extend the markup with a second layer:
<fieldset class='cc-prefs' hidden>
<legend>Choose your cookies</legend>
<label><input type='checkbox' checked disabled> Strictly necessary (always on)</label>
<label><input type='checkbox' name='analytics'> Analytics</label>
<label><input type='checkbox' name='marketing'> Marketing</label>
<button type='button' data-cc='save'>Save my choices</button>
</fieldset>
if (action === 'save') {
var box = banner.querySelector('.cc-prefs');
decide({
essential: true,
analytics: box.querySelector('[name=analytics]').checked,
marketing: box.querySelector('[name=marketing]').checked
});
}
Checkboxes are unchecked by default, which is exactly what regulators expect.
Bonus: serving and logging consent with Express
If your site runs on Express, two small additions make the setup production ready.
1. Serve the script with long cache headers so it is fetched once:
app.use('/js', express.static('public/js', { maxAge: '1y', immutable: true }));
2. Log proof of consent without storing anything identifying. A hashed IP plus the decision is usually enough to demonstrate compliance:
const crypto = require('crypto');
app.post('/api/consent', express.json(), (req, res) => {
const { categories, v } = req.body || {};
if (!categories) return res.status(400).json({ ok: false });
const hash = crypto.createHash('sha256')
.update(req.ip + process.env.CONSENT_SALT)
.digest('hex').slice(0, 16);
// persist { hash, v, categories, at: new Date() } in your database
res.json({ ok: true });
});
Call it from saveConsent() with navigator.sendBeacon('/api/consent', blob) so it never blocks the UI. And remember: never call res.cookie() for analytics or attribution before the client has told you consent exists.
Hand-coded banner vs hosted CMP: the trade-off
| Criteria | This HTML/CSS/JS banner | Hosted CMP or plugin |
|---|---|---|
| Payload | About 2 KB, first party | Typically 50 to 150 KB plus extra connections |
| Design control | Total, it is your own DOM | Limited to the vendor theme editor |
| Cookie scanning and auto-blocking | Manual, you decide what to block | Automated |
| Multi-region rules (GDPR, CCPA, TCF) | You implement what you need | Built in, useful for ad tech |
| Cost | Free | Free tier or monthly subscription |
Use the hand-coded version when you have a handful of tags and full control of your templates. Use a CMP if you sell programmatic advertising, need the IAB TCF framework, or must document dozens of vendors across several jurisdictions.

Accessibility and UX checklist
- Move focus to the banner when it appears, and keep a visible
:focus-visiblestyle - Contrast ratio of at least 4.5:1 for text on the banner background
- Do not trap keyboard users: the banner should not block access to your privacy policy link
- Never use a dark pattern such as a greyed-out reject button or a hidden close icon
- Keep the banner out of the way of your main content on mobile, and avoid full-screen overlays unless your legal team requires them
How to test it in five minutes
- Open a private window, load the page, and check the Network tab: no request to googletagmanager.com should appear.
- Click Accept all and confirm the tag request fires immediately.
- Open DevTools, Application, Local Storage, and check the
cc_consent_v1entry contains your categories and timestamp. - Reload: the banner must stay hidden and analytics must load without a second click.
- Click Cookie settings, choose reject, reload, and verify the tracker is gone and its cookies are deleted.
- Run a Lighthouse audit before and after to confirm the banner costs you nothing measurable.
FAQ
How do you make a cookie consent banner?
Add a fixed-position div with a short notice and two buttons, style it with CSS, then use JavaScript to store the choice in localStorage and inject optional scripts only when consent is granted. Keep a footer link that reopens the banner so users can change their decision. The full code is in the steps above.
What is the best free cookie consent banner?
For a small or medium site, the best free option is the one you control: a few kilobytes of your own HTML, CSS and JS, as shown here. Open source and free-tier tools are a good fit when you need automated cookie scanning, consent logs and multi-jurisdiction rules out of the box.
What are the legal requirements for a cookie banner?
Under the GDPR and ePrivacy rules, consent must be requested before non-essential cookies are set, must be an active opt-in with no pre-ticked boxes, must offer rejection as easily as acceptance, must explain the purposes involved, and must be withdrawable at any time. You should also be able to prove when consent was given and for which version of your cookie list.
Is it illegal to not have a cookie banner?
It is not the missing banner that is illegal, it is setting non-essential cookies without valid consent. A site that only uses strictly necessary cookies does not need a consent banner, though it still needs a clear privacy policy. As soon as you add analytics, embeds or advertising tags, you need a consent mechanism.
Does a cookie banner hurt SEO or Core Web Vitals?
A lightweight, fixed-position banner has no measurable impact. Problems come from heavy third-party consent scripts that block rendering, and from full-screen overlays that can be treated as intrusive interstitials. Keep the script deferred, keep the markup out of the layout flow, and make sure crawlers can still read your content.
Should I use localStorage or a cookie to store the choice?
Use localStorage for client-side tags: it is simpler and adds no weight to HTTP requests. Use a first-party cookie if your server needs the value to decide what to render, or if you need the decision shared across subdomains.
Key takeaway
A compliant cookie consent banner in HTML is not a product you have to buy, it is a pattern: block by default, ask clearly, store the answer, then load. Roughly 80 lines of code give you the GDPR basics, a banner that matches your design system perfectly, and a page that stays fast.
