How to Add a Cookie Consent Banner to a Website Without a Plugin (HTML, CSS, JS)

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.

cookie banner website

What we are building

  • A fixed bottom banner with Accept all, Reject non-essential and an optional Customize step
  • The decision stored in localStorage as JSON, with a version and a timestamp
  • Google Analytics 4 (or any other tag) injected only when analytics is granted
  • A reusable cc:consent DOM 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:

  1. The buttons are real <button> elements, not links or divs, so keyboard and screen reader users get them for free.
  2. Reject and accept share the same cc-btn base 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}
}
cookie banner website

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.

cookie banner website

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.

cookie banner website

Accessibility and UX checklist

  • Move focus to the banner when it appears, and keep a visible :focus-visible style
  • 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

  1. Open a private window, load the page, and check the Network tab: no request to googletagmanager.com should appear.
  2. Click Accept all and confirm the tag request fires immediately.
  3. Open DevTools, Application, Local Storage, and check the cc_consent_v1 entry contains your categories and timestamp.
  4. Reload: the banner must stay hidden and analytics must load without a second click.
  5. Click Cookie settings, choose reject, reload, and verify the tracker is gone and its cookies are deleted.
  6. 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.

Recent Posts

No Posts Found!

Categories

Tags

    Subscribe

    You have been successfully Subscribed! Ops! Something went wrong, please try again.

    About Us

    Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

    Contact Info

    Copyright © 2022 Express Jam Studio. All Rights Reserved.