Asks which categories of cookie may be used, remembers the answer — the refusals included — and holds the tags back until it has one. Reach for it on anything public that measures its visitors: a marketing or landing page, a documentation site with analytics on it, a signup funnel, a site facing the EU or UK under the GDPR and the ePrivacy Directive or Brazil under the LGPD, a site that has to honour Global Privacy Control for California, the "Cookie settings" link in a footer, and the prompt that has to go back to everybody after a new vendor is added. Common asks it answers: "cookie consent react", "cookie banner component", "shadcn cookie consent", "gdpr cookie banner react", "cookie consent next.js", "cookie consent app router", "consent management react", "cookie preferences modal", "granular cookie consent categories", "accept reject cookies component", "cookie consent localStorage", "remember cookie choice", "store cookie consent decision", "load google analytics after consent", "defer gtag until consent", "conditionally load script after consent react", "block analytics until consent", "consent mode react", "do not track react", "navigator.doNotTrack", "globalPrivacyControl react", "respect GPC signal", "honour do not track javascript", "cookie settings link footer", "reopen cookie preferences", "withdraw cookie consent", "re-ask consent after adding a category", "consent version bump", "cookie banner hydration mismatch", "cookie banner flashes on every page load", "banner reappears after accepting", "cookie consent 同意", "cookie バナー react", "GDPR 同意 コンポーネント", "同意するまで解析タグを止める". **The banner is the easy half, and a banner on its own is decoration.** Asking the question changes nothing about what the page already did: a tag in the document `<head>`, a `<script>` in a root layout, a module imported for its side effects — all of it has fetched and fired before anybody was asked, and no banner can un-send a request. So the gate ships with the question. `<ConsentGate category="analytics"><Script src="…" /></ConsentGate>` renders nothing until that category is granted, which means the element is never created and the script is never fetched; `useConsent("analytics")` is the same answer as a boolean for a branch that is not a subtree. That is the whole honest claim, and the line it draws is drawn in the open: this reaches what React renders and nothing else. Unmounting on withdrawal is the same story in reverse — removing a `<script>` element does not unload what it installed, so withdrawal stops the next page rather than this one, and if that matters you reload after it. **A refusal is an answer, and it is written down as explicitly as a grant.** The common implementation stores `cookiesAccepted = true` on the agreement and nothing at all on the refusal, so the only visitor it remembers is the one who said yes and the one who said no is asked again on every page of every visit — the nagging is aimed precisely at the people who already declined. What is stored here is every category and its answer, false ones included, so a refusal is a complete answer and the banner does not come back. **The two browser-level refusal signals are read exactly, because the value that means the opposite is truthy.** `navigator.doNotTrack` is compared against the string `"1"` and nothing else: `"0"` means "you may track me", and it is a non-empty string, so `if (navigator.doNotTrack)` reads an explicit permission as a refusal. `navigator.globalPrivacyControl` is feature-detected and compared against `true`, because `false` is a browser saying it has no signal to send. Neither can be assumed present — measured in Chrome 152 today, `"globalPrivacyControl" in navigator` is false while `doNotTrack` *is* on the object and reads `null`, so each needs its own check and absence means nothing either way. A browser carrying one of these is not asked at all, since it has already answered, and that answer is never written to storage: it is the live reading of a setting rather than a record of a choice, so when the setting goes away the question comes back. An explicit choice made in the panel outranks the signal, because a global default that silently overrides the switches in front of somebody makes those switches a lie and leaves no way for anyone to opt in. **There are three states here, not two, and the third is why nothing ever flashes.** Before storage has been read the answer is not "no" — it is *not yet known*, and the two have different consumers. Modelled as "undecided", the banner renders on the server and on the first client pass, so every returning visitor sees the banner they dismissed flash on every page load; modelled as "granted", the tag fires for the person who is about to refuse. Unknown is treated as already-answered by the banner and as refused by the gate, which is the only combination that is wrong in neither direction, and it also means the component renders `null` during hydration rather than reading storage mid-render and tripping a mismatch. **An answer storage would not keep still applies to this page.** A private window, a partitioned context or a full quota makes the write fail, and for those visitors *every* answer is in that state. Without somewhere to hold it, the next read of storage finds nothing and puts the banner back in front of somebody who has just answered it — on every re-render. It is held in memory instead, keyed by the question it answered, so a bumped version or a newly added category still asks. A read that throws is treated as nothing stored, which asks again: losing an answer is recoverable, acting on one nobody gave is not. **The question can change, and an old answer is then an answer to a different question.** An answer that does not cover every category currently being asked about is treated as unfinished rather than as consent to the new one — the answers it does hold become the defaults in the panel and the banner comes back for the rest. `version` is for the other case, where a category keeps its id and changes its meaning, and bumping it asks everybody again. **Withdrawing is as easy as giving, and the control that does it can live anywhere.** The panel reopens on the current answer, from a plain link in a footer, because the decision has to stay changeable and under the GDPR withdrawal cannot be harder than consent. The state of that panel is held with the decision rather than inside the banner, which is the part that looks fine and is not: a component holding `isOpen` in its own `useState` gives the footer link a panel inside the footer and leaves the mounted banner shut. Focus moves into the panel when it opens and goes back to the link when it closes; an answer takes the whole region away, and the browser decides where focus lands after that rather than this pretending to. **The refusal is one click, in the banner, beside the agreement.** "Allow all" and "Necessary only" sit together at equal weight, with the per-category panel as a third option rather than as the only route to saying no. A refusal buried a step deeper than the agreement is a dark pattern, and the regulators that require the question require this as well. The strictly-necessary category is shown with the words "Always on" rather than a switch that is disabled: a control that invites a click and then ignores it is worse than a sentence. The switches are `role="switch"` with `aria-checked`, each labelled and described by its own category rather than by one shared id, every control is a real `type="button"` so a banner inside a form cannot submit it, decorative marks are `aria-hidden`, and the region is a labelled `role="region"` — not a `dialog` with `aria-modal`, which would claim the rest of the page does not exist while the page is fully usable behind it, and would be the one lie a screen reader cannot work around. An answer given in another tab arrives through the `storage` event, so a gate does not stay shut after the person consented next door. Theme tokens throughout with no hardcoded colours, one lucide icon, and nothing else added. **It is not a tag manager and it is not legal advice.** It holds a decision and gates what it is given; it does not enumerate the cookies on your origin, delete the ones already set, talk to an IAB framework, or produce a record you could show a regulator. The default category copy is a starting point and not a description of your site. Official shadcn/ui has nothing here, and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, code-block and questionnaire and trace 404 on both style tracks — and searching all 223,287 bytes of it, the strings consent, cookieConsent, doNotTrack, globalPrivacyControl, dataLayer, gtag, analytics, localStorage, categories and necessary are every one of them a zero hit. The single occurrence of `document.cookie` is `sidebar` saving whether it is open, read in context today to be sure. Within pulld it sits above announcement-bar, which shows a message and remembers that it was dismissed, and beside terms-scroll-gate, which makes somebody see a document before they can act on it. Neither of those holds a per-category decision or gates anything on one; this is the only component here whose answer other components read.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/cookie-consent.json"