A small first-party cookie consent kit for Webflow projects. One static script blocks trackers until the visitor agrees; one native Webflow component is the banner, styled in the Designer with your fonts, colors, and interactions.
- No server, no account, no per-site plan. Consent lives in a first-party cookie.
- Banner and preferences panel are regular Webflow elements. The script only reads
data-tcattributes. - Scripts, iframes, and images stay blocked until their category is allowed. Known tracker hosts are blocked even when you forget to tag them.
- Honors Global Privacy Control. Opt-in (GDPR) and opt-out (CCPA) modes, or rules by region: GDPR banner in Europe, opt-out notice in US privacy states, no banner elsewhere.
src/script source (tiny-consent.jsentry,consent.js,blocker.js,ui.js,blocklist.js,vendors.js,cleanup.js,geo.js)dist/built bundle to host (tiny-consent.js,tiny-consent.min.js)index.htmlonboarding docs: the setup steps with copy buttons, a head-code configurator, Copy component for Webflow, and a live preview of the component (the exact tree, driven by the real script)webflow-paste.jsturns the component tree inindex.htmland the component section ofstyles.cssinto a Webflow clipboard payloadtest/Vitest suite
npm install
npm run build # dist/tiny-consent.js + dist/tiny-consent.min.js
npm run dev # http://localhost:8787/ with rebuild on change
npm testThe docs page is the shortest path: open npm run dev, follow the six steps, and use the copy buttons.
The script is published on jsDelivr from this repo. @1 includes later 1.x releases. Use @main when a site should follow the latest build. To host it yourself, upload dist/tiny-consent.min.js and point the script tag at that file.
Site settings → Custom code → Head code. Put this first, before any analytics or pixel snippet, with no async or defer:
<script src="https://cdn.jsdelivr.net/gh/povio/tiny-consent@1/dist/tiny-consent.min.js"
data-tc-mode="opt-in"
data-tc-cookie-days="180"></script>| Attribute | Default | Meaning |
|---|---|---|
data-tc-mode |
opt-in |
opt-in: nothing optional runs until the visitor accepts. opt-out: everything runs until the visitor rejects (CCPA style). |
data-tc-cookie-days |
180 |
Lifetime of the consent cookie. |
data-tc-block |
Extra hosts to block, host[:category], comma separated. cdn.example.com:analytics, pixel.example.com. Category defaults to marketing. Use :essential to let a host through the default list, e.g. googletagmanager.com:essential. |
|
data-tc-reload |
true |
Reload the page when a category that was allowed becomes denied. Running scripts cannot be unloaded, so this is how they stop. Set false to skip. |
data-tc-cookie-name |
tc_consent |
Cookie name. |
data-tc-purge |
true |
Delete the known cookies of vendors in denied categories (on load, after every change, and when the page is left). Set false to leave cookies alone. |
data-tc-vendors |
Vendor ids to list in the panel even when nothing on the page matches them (server-side tags), e.g. meta-pixel, hubspot. See Vendor list. |
|
data-tc-geo |
Rules by region. auto: IP lookup with timezone fallback. timezone: browser timezone only, no request. Overrides data-tc-mode. See Rules by region. |
|
data-tc-regions |
see below | Region → policy table, key:policy comma separated. Policies: opt-in, opt-out, none. |
data-tc-geo-url |
geojs.io | Your own lookup endpoint for auto. JSON with country_code/country and region_code/region, or Cloudflare cdn-cgi/trace text. |
data-tc-region |
Force a region (us-ca, de, eu) and skip detection. Also window.TinyConsentRegion. |
Then publish. Tags added under Webflow Apps & Integrations (the Google Analytics and Facebook Pixel fields) load before custom code and cannot be blocked. Paste those snippets into Head code instead.
Categories are essential, analytics, marketing, personalization. Essential always runs.
Scripts — set type="text/plain", move src to data-tc-src, add the category:
<!-- before -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments)} gtag('js', new Date()); gtag('config', 'G-XXXX');</script>
<!-- after -->
<script type="text/plain" data-tc-src="https://www.googletagmanager.com/gtag/js?id=G-XXXX" data-tc-category="analytics"></script>
<script type="text/plain" data-tc-category="analytics">window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments)} gtag('js', new Date()); gtag('config', 'G-XXXX');</script>A tag that lists several categories (data-tc-category="analytics, marketing") runs only when all of them are allowed.
Iframes and images (YouTube, Vimeo, Google Maps, tracking pixels) — replace src with data-tc-src in a Code Embed:
<iframe data-tc-src="https://www.youtube-nocookie.com/embed/VIDEO" data-tc-category="marketing" allowfullscreen></iframe>Placeholder — any element with data-tc-element="placeholder" and a category is removed once that category is allowed. Put a message and an open-preferences button inside it, over the blocked embed.
Automatic blocking — scripts and iframes from hosts in src/blocklist.js (Google Analytics, GTM, Meta, Hotjar, LinkedIn, TikTok, HubSpot, YouTube, Intercom, …) are blocked even when they are not marked, including scripts injected later by other scripts. Scripts that already executed before Tiny Consent loaded, and tags Webflow injects ahead of custom code, cannot be caught. Add data-tc-ignore to a tag to exclude it from automatic blocking.
Remove <noscript> fallbacks from tracker snippets. They fire without consent.
On the docs page (npm run dev, step 4) press Copy component for Webflow. The clipboard then holds the component in Webflow's own clipboard format (@webflow/XscpData): native Div, Heading, Paragraph, Button, and Checkbox elements, one class per element with the default theme values, and all data-tc attributes set. In the Designer, select the footer (or any component that is on every page), enter the component, and paste.
The payload is generated from the component tree in index.html plus the component section of styles.css, so it always matches the live preview. During generation var() becomes the default value, color-mix() becomes rgba(), :hover becomes the Hover state, and the max-width: 560px media query becomes the Tablet/Mobile breakpoint. After pasting, swap the flat colors for your site's variables.
If nothing appears, Copy as HTML embed is the fallback: paste into a Code Embed element. Works instantly, not styleable in the Designer.
The script finds elements by attribute, so names, classes, copy, order of buttons, and interactions are free.
Div data-tc="root" position relative, z-index 9999 (zero-size anchor)
├─ Div data-tc="banner" position fixed, bottom-right (or any placement)
│ ├─ Div brand row: mark + Heading "We use cookies"
│ ├─ Paragraph description
│ ├─ Text link privacy policy
│ └─ Div actions
│ ├─ Button data-tc-action="reject-all"
│ ├─ Button data-tc-action="open-preferences"
│ └─ Button data-tc-action="accept-all"
├─ Div data-tc="preferences" position fixed, inset 0, centered
│ ├─ Div backdrop, data-tc-action="close"
│ └─ Div dialog
│ ├─ Button data-tc-action="close" the X (an SVG embed inside)
│ ├─ Heading / Paragraph
│ ├─ Form Block (Webflow checkboxes need one)
│ │ └─ Div data-tc-category="analytics" data-tc-element="accordion" one row per category
│ │ ├─ Div head
│ │ │ ├─ Button data-tc-action="toggle"
│ │ │ │ └─ Div data-tc-element="chevron" SVG embed inside; rotates while open
│ │ │ ├─ Div title data-tc-action="toggle" clicking the title opens the row too
│ │ │ └─ Checkbox field data-tc-element="switch" Essential: a Div with a badge instead
│ │ │ ├─ Checkbox input name="Analytics"
│ │ │ ├─ Label hidden, for the input
│ │ │ └─ Div knob
│ │ ├─ Paragraph description
│ │ └─ Div data-tc-element="details" vendor list, hidden until the chevron opens it
│ │ ├─ Div data-tc-element="accordion" data-tc-vendor="sample" template, cloned per detected vendor
│ │ │ ├─ Div head data-tc-action="toggle": name (data-tc-field="vendor-name"), link (data-tc-field="vendor-privacy"), chevron button
│ │ │ └─ Div data-tc-element="details"
│ │ │ └─ Div data-tc-field="cookie" one per cookie: cookie-name, cookie-purpose, cookie-duration
│ │ └─ Paragraph data-tc-field="empty" shown when nothing was detected for the category
│ └─ Div actions
│ ├─ Button data-tc-action="reject-all"
│ ├─ Button data-tc-action="save"
│ └─ Button data-tc-action="accept-all"
└─ Button data-tc="float" data-tc-action="open-preferences"
├─ Div cookie icon (SVG embed) position fixed, bottom-left; shown once a choice exists
└─ Div "Preferences"
Rules:
- Keep every element inside
data-tc="root"and keep the attributes. Everything else is yours. - Set attributes in Element settings → Custom attributes. Put
data-tc-categoryon the row Div around each switch; the script finds the checkbox input inside. A row without a checkbox (Essential) is display only. - Switches: the checkbox input sits invisibly over the track; the knob is a plain Div. The script adds the combo class
is-onto thedata-tc-element="switch"wrapper (or the input's parent if there is no wrapper) and to its children while the category is allowed, so style the on state astc-switch+is-onandtc-switch_knob+is-on. - Accordions: any
data-tc-action="toggle"flipsdata-tc-openon its nearestdata-tc-element="accordion"; the script hides that accordion'sdata-tc-element="details"while closed, rotates itsdata-tc-element="chevron"while open, and adds the combo classis-opento the accordion, every toggle in it, and the chevron. An accordion can have more than one toggle (the chevron button and the row title both carry the attribute), and all of them update together. Nest them as deep as you like. In the Designer everything is expanded; on the published site everything starts closed. - The
data-tc="float"button is hidden while the banner or the panel is open and until the visitor has made a choice. A footer link withdata-tc-element="open-preferences"still works if you prefer that, or want both. - Use
buttonelements (Button or Link Block withtype="button") inside the Form Block. The script also cancels form submission inside the component. - Put the component in a symbol that already sits on every page (navbar or footer component). Webflow has no API that injects a component site-wide.
- Leave the banner visible in the Designer. On the published site the script hides it until it is needed, so there is no flash and you can still see it while designing. Hide the preferences panel and the float button while you work and set them back before publishing.
The [data-tc="root"] block at the end of index.html is the same tree in plain HTML. Use it as the reference when building the Webflow version.
Category labels are plain text. Rename "Analytics" to "Analytik" or "Statistics" in the Designer; the attribute value stays analytics.
The vendor cards inside each row are generated. When the page loads, and again every time the panel opens, the script scans every script and iframe on the page (the URL in src, or in data-tc-src for tags the blocker neutralised) and the text of inline snippets and noscript tags, and matches them against a built-in registry: Google Analytics, Tag Manager, Meta Pixel, Google Ads, LinkedIn, TikTok, Microsoft Ads, HubSpot, Hotjar, Clarity, YouTube, Vimeo, Google Maps, Intercom, Crisp, Drift, and every other host on the default block list (src/vendors.js). Each match becomes a card with the vendor name, privacy policy link, and the cookies it sets (name, purpose, duration). Tiny Consent itself is always listed under Essential with the configured cookie lifetime.
How the markup drives it:
- The first block with
data-tc-vendorin a row's details is the template. Give itdata-tc-vendor="sample"(that is what the paste ships). The script clones it per vendor, fills thedata-tc-fieldslots (vendor-name,vendor-privacy,cookie,cookie-name,cookie-purpose,cookie-duration), and removes the sample. A row without its own template borrows the first one it finds. - A block with any other value, e.g.
data-tc-vendor="in-house-pixel", is hand-written: it stays as it is, and a registry vendor with the same id is not generated twice. - An element with
data-tc-field="empty"inside the details is shown when nothing was detected for that category and gets thehiddenattribute otherwise.
Per-site additions and overrides go in Head code before or after the script:
<script>
window.TinyConsentVendors = [
// Add a vendor the registry does not know. hosts match script/iframe URLs and inline snippets.
// cookies are shown in the panel; purge lists every other cookie name (with * wildcards)
// to delete once the category is denied.
{ id: 'acme-chat', name: 'Acme Chat', category: 'personalization',
privacy: 'https://acme.example/privacy', hosts: ['cdn.acme-chat.example'],
cookies: [{ name: 'acme_sid', purpose: 'Keeps the chat session.', duration: '1 day' }],
purge: ['acme_*'] },
// Override only some fields of a built-in vendor (matched by id).
{ id: 'google-analytics', name: 'Google Analytics 4' },
];
</script>data-tc-vendors="meta-pixel, hubspot" on the script tag lists registry vendors even when nothing on the page matches (tags fired server-side). TinyConsent.vendors() re-scans and returns the current list.
styles.css styles the component with seven custom properties:
--tc-font font family
--tc-bg panel background
--tc-text primary text
--tc-muted secondary text
--tc-accent primary button and switch color
--tc-on-accent text on the accent color
--tc-radius corner radius (panels cap at 28px so pill buttons stay sane)In Webflow, create a variable collection with the same seven values and bind the component's colors, font, and radius to it. Two ways to use the component across sites:
- Shared Library, stay linked. Share the source site as a Library, install it on a client site, and override the variables with the client's collection (or variable modes for multi-brand). Layout and copy follow the library; updates cascade.
- Install, unlink, restyle. Install the Library, then uninstall it so the component becomes a local component. Now the whole Style panel is open: fonts, spacing, interactions, button order. This is the normal agency starter-kit workflow and the right choice when a client needs a different layout.
- First visit: banner shows. Nothing optional runs (opt-in) or everything runs (opt-out).
- Accept all / reject all / save: the choice is stored in
tc_consent(SameSite=Lax,Secureon https) fordata-tc-cookie-daysdays. Allowed tags run immediately. The panel closes and focus returns to the trigger. - Returning visit: no banner. Allowed tags run as the page parses, blocked tags stay blocked.
- Downgrade (a category goes from allowed to denied): the cookies that category's vendors are known to set (
_ga,_ga_*,_fbp,_gcl_au,__hstc, … from the registry, pluswindow.TinyConsentVendors) are deleted on every domain variant of the current host, then the page reloads so running trackers stop. The same purge runs on every load for whatever is denied, so a tag that slipped through cannot leave a cookie behind. Limits: scripts cannot touch HttpOnly cookies, third-party cookies set by iframes or pixel responses, or cookies on another registrable domain;localStorageis not cleared. - Global Privacy Control (
navigator.globalPrivacyControl): marketing is denied by default in opt-out mode. - Escape closes the preferences panel. Closing without a stored choice returns to the banner.
With data-tc-geo="auto" the banner follows the visitor's law instead of one global mode. Every page view starts strict (nothing optional runs, banner hidden), the region is resolved, then the policy for that region applies. A stored choice always wins; an unknown region gets the opt-in banner.
| Policy | Default regions | Before a choice |
|---|---|---|
opt-in |
eu (EU + EEA), gb, ch, br, ca-qc |
Nothing optional runs, the banner asks. |
opt-out |
us-ca, us-va, us-co, us-ct, us-ut, us-tx, us-or, us-mt, us-ia, us-de, us-nh, us-nj, us-tn, us-mn, us-md, us-in, us-ky, us-ne, us-ri |
Everything runs (marketing off under GPC), the banner is a notice with Do not sell or share. |
none |
* |
No banner, consent is recorded as granted, the Preferences button stays. |
data-tc-regions replaces the table; the most specific key wins (us-ca over us over eu over *). data-tc-regions="eu:opt-in, gb:opt-in, us-ca:opt-out, *:opt-in" asks everyone outside the listed regions too.
Detection: auto fetches https://get.geojs.io/v1/ip/geo.json once per session (no cookies, result in sessionStorage), or your data-tc-geo-url; on failure it falls back to the browser timezone. timezone never makes a request and is coarse: a European zone is treated as eu, a US zone takes the strictest us-* policy in the table, so an uncertain guess never relaxes the rules. Name the lookup service in your privacy policy.
Component copy per policy: give any element inside the component data-tc-variant="opt-in", "opt-out", "none", or several space separated, and it shows only under those policies. The shipped banner has GDPR text with Reject all / Accept all and CCPA text with Do not sell or share / OK. <html> carries data-tc-policy and data-tc-region for your own CSS.
TinyConsent.getRegion() // { country, region?, source, policy } or null
TinyConsent.getPolicy() // 'opt-in' | 'opt-out' | 'none'
TinyConsent.setRegion('us-ca') // force a region; re-applies the policy if no choice is stored
document.addEventListener('tc:region', e => e.detail) // { region, policy }TinyConsent.getConsent() // { chosen, essential, analytics, marketing, personalization, t, v }
TinyConsent.isAllowed('analytics') // boolean
TinyConsent.setConsent({ analytics: true, marketing: false })
TinyConsent.acceptAll()
TinyConsent.rejectAll()
TinyConsent.open() // preferences panel
TinyConsent.close()
TinyConsent.reset() // forget the choice, show the banner
TinyConsent.vendors() // re-scan the page; [{ id, name, category, privacy, cookies }]
TinyConsent.purge() // delete cookies of denied categories now; returns the names removed
document.addEventListener('tc:consent', e => e.detail) // on load and after every change
document.addEventListener('tc:purge', e => e.detail) // { cookies: [names removed], categories }
document.addEventListener('tc:block', e => e.detail.element) // a tag was auto-blocked by hostname
document.addEventListener('tc:activate', e => e.detail.element) // a marked tag ran or was revealedUse tc:consent to update Google Consent Mode or push to a GTM dataLayer if you run tags through a container.
tc_consent holds URL-encoded JSON: {"v":1,"t":"2026-10-07T10:34:58.928Z","a":1,"m":1,"p":1}. t is the time of the decision; a, m, p are analytics, marketing, personalization. Essential is implicit. A different v is treated as no consent, so bumping VERSION in src/consent.js re-asks everyone.
This kit covers the part of a consent tool that runs on the site. The vendor list reflects the tags present in the page the visitor is looking at, matched against a fixed registry; a crawl of the whole site, consent-log storage and analytics, AI tracker descriptions, and policy-page generation are separate products and are out of scope here. The kit does not provide legal advice; which categories you need and what your policy says is still your call.