Skip to content
Product block

Cookie Consent Banner

Per-category consent where refusing is exactly as easy as accepting, nothing non-essential is pre-ticked, and the page behind it stays readable — the shape the law expects, wired to your own script gating.

232 lineslucide-reactAdded 24 Aug 2026Updated 13 Sept 2026
  • cookies
  • consent
  • gdpr
  • privacy
  • banner

What's included

  • components/cookie-consent.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add cookie-consent

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/cookie-consent.tsx
'use client'

/**
 * <CookieConsent> — a consent banner with real per-category choices.
 *
 * READ THIS BEFORE SHIPPING IT. This is a UI component, not compliance.
 * It renders the choice and reports it through `onDecide`; it does not
 * block a single script on its own. If your analytics or ad tags load from
 * the document head regardless of what this returns, the banner is
 * decoration and arguably worse than nothing — it tells a visitor their
 * choice was honoured when it was not. Wiring `onDecide` to the thing that
 * actually gates loading is the work, and it is yours.
 *
 * With that said, the shape here is the one the law expects, and most
 * banners get it wrong in the same four ways:
 *
 *   Reject is as easy as accept. Both are buttons, both are one click,
 *   side by side. A banner where accepting is a button and refusing is a
 *   link into a settings page is the pattern regulators have repeatedly
 *   found invalid, and it is the most common mistake in the wild.
 *
 *   Non-essential categories default to OFF. Consent is opt-in, so a
 *   pre-ticked analytics box is not consent. Essential renders checked and
 *   disabled, because it is not a choice and should not pretend to be.
 *
 *   It does not trap. No overlay, no scroll lock, no focus trap — a cookie
 *   wall that blocks reading until you answer makes consent a condition of
 *   access, which is the thing "freely given" rules out. The page behind
 *   it stays usable.
 *
 *   It is announced, not sprung. `role="dialog"` with `aria-modal={false}`
 *   and a label, so a screen reader identifies it as a region asking for a
 *   decision rather than a stray group of buttons at the end of the page.
 *
 * Nothing is persisted here. Where the decision belongs — a cookie your
 * server reads, a consent-management platform, localStorage — depends on
 * where it has to be enforced, and guessing wrong writes a record in a
 * place that never gets read.
 *
 * POSITIONING. The banner is `absolute` inside a `relative` frame, so it
 * stays inside whatever renders it. Mounting it for real means one change:
 * drop the frame and swap `absolute` for `fixed`. It is written this way
 * round because a `fixed` element ignores every container it is in — in a
 * gallery of previews it escapes its own card and covers the page, and a
 * component that cannot be previewed beside its neighbours is one nobody
 * finds.
 */

import * as React from 'react'
import { Cookie } from 'lucide-react'

export interface ConsentCategory {
  id: string
  name: string
  description: string
  /** Cannot be refused, so it renders checked and disabled. */
  essential?: boolean
}

export interface CookieConsentProps {
  heading?: string
  body?: React.ReactNode
  categories?: ConsentCategory[]
  policyHref?: string
  policyLabel?: string
  /** Receives the ids the visitor allowed. Wire this to what loads scripts. */
  onDecide?: (allowed: string[]) => void
  className?: string
}

const DEFAULT_CATEGORIES: ConsentCategory[] = [
  {
    id: 'essential',
    name: 'Essential',
    description:
      'Signing in, security, and remembering what is in your basket. Always on.',
    essential: true,
  },
  {
    id: 'analytics',
    name: 'Analytics',
    description: 'Which pages get used, so we know what to build next.',
  },
  {
    id: 'marketing',
    name: 'Marketing',
    description: 'Measuring whether an ad you clicked led anywhere.',
  },
]

/**
 * One class string for both decisions, referenced twice rather than
 * written twice — so the two cannot drift apart in a later edit, which is
 * exactly how a compliant banner quietly becomes a non-compliant one.
 */
const DECISION_BUTTON =
  'rounded-xl border border-border/60 bg-background px-4 py-2.5 text-sm font-semibold transition-colors hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'

export function CookieConsent({
  heading = 'Cookies, and what they are for',
  body = 'Essential cookies keep you signed in. The rest stay off until you turn them on, and you can change your mind at any time.',
  categories = DEFAULT_CATEGORIES,
  policyHref = '/privacy',
  policyLabel = 'Privacy policy',
  onDecide,
  className = '',
}: CookieConsentProps) {
  // Per-instance ids. A literal id in a reusable component is a
  // collision waiting for the second copy on the page — and a <label>
  // then resolves to whichever input rendered first.
  const uid = React.useId()
  const [open, setOpen] = React.useState(true)
  const [showDetail, setShowDetail] = React.useState(false)

  // Opt-in: only what cannot be refused starts enabled.
  const [allowed, setAllowed] = React.useState<string[]>(() =>
    categories.filter((c) => c.essential).map((c) => c.id),
  )

  const essentialIds = categories.filter((c) => c.essential).map((c) => c.id)
  const allIds = categories.map((c) => c.id)

  const decide = (ids: string[]) => {
    onDecide?.(ids)
    setOpen(false)
  }

  if (!open) return null

  return (
    // The containing frame. In a real mount this goes away and the banner
    // below becomes `fixed` — see POSITIONING above.
    <div className={`relative min-h-[27rem] w-full ${className}`}>
      <div
        role="dialog"
        // Not modal on purpose: see the note above on cookie walls.
        aria-modal={false}
        aria-labelledby={`${uid}-cookie-consent-heading`}
        className="absolute inset-x-0 bottom-0 z-10 p-4 sm:p-6"
      >
        <div className="mx-auto max-w-3xl rounded-2xl border border-border/60 bg-card p-5 shadow-lg sm:p-6">
          <div className="flex gap-4">
            <div className="hidden h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background sm:flex">
              <Cookie aria-hidden className="h-5 w-5 text-muted-foreground" />
            </div>
            <div className="min-w-0">
              <h2 id={`${uid}-cookie-consent-heading`} className="font-semibold">
                {heading}
              </h2>
              <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
                {body}{' '}
                <a
                  href={policyHref}
                  className="text-primary underline-offset-4 hover:underline"
                >
                  {policyLabel}
                </a>
              </p>
            </div>
          </div>

          {showDetail ? (
            <fieldset
              id={`${uid}-cookie-consent-categories`}
              className="mt-5 space-y-3 border-t border-border/60 pt-5"
            >
              <legend className="sr-only">Cookie categories</legend>
              {categories.map((c) => (
                <label key={c.id} className="flex items-start gap-3 text-sm">
                  <input
                    type="checkbox"
                    checked={allowed.includes(c.id)}
                    disabled={c.essential}
                    onChange={(e) =>
                      setAllowed((prev) =>
                        e.target.checked
                          ? [...prev, c.id]
                          : prev.filter((id) => id !== c.id),
                      )
                    }
                    className="mt-0.5 h-4 w-4 shrink-0 accent-primary disabled:opacity-60"
                  />
                  <span>
                    <span className="font-medium">{c.name}</span>
                    <span className="block text-muted-foreground">
                      {c.description}
                    </span>
                  </span>
                </label>
              ))}
            </fieldset>
          ) : null}

          {/* Both decision buttons carry the SAME classes — same border,
              same background, same size, adjacent. Not an oversight and not
              a style to tidy up later: giving accept a filled primary
              treatment against an outlined reject is the visual version of
              burying reject in a settings page, and regulators have treated
              unequal prominence as invalidating consent. If a design system
              insists the primary action be loud here, the honest fix is to
              make both loud, never just this one. */}
          <div className="mt-5 flex flex-col flex-wrap gap-2 sm:flex-row sm:justify-end">
            <button
              type="button"
              onClick={() => setShowDetail((v) => !v)}
              aria-expanded={showDetail}
              aria-controls={`${uid}-cookie-consent-categories`}
              className="rounded-xl px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground sm:me-auto"
            >
              {showDetail ? 'Hide options' : 'Choose what to allow'}
            </button>
            <button
              type="button"
              onClick={() => decide(essentialIds)}
              className={DECISION_BUTTON}
            >
              Reject non-essential
            </button>
            <button
              type="button"
              onClick={() => decide(showDetail ? allowed : allIds)}
              className={DECISION_BUTTON}
            >
              {showDetail ? 'Save choices' : 'Accept all'}
            </button>
          </div>
        </div>
      </div>
    </div>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/cookie-consent.tsx and import it where you need the section:

import { CookieConsent } from '@/components/cookie-consent'

Customize

3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
headingstring'Cookies, and what they are for'
bodyReact.ReactNode—
categoriesConsentCategory[]DEFAULT_CATEGORIES
policyHrefstring'/privacy'
policyLabelstring'Privacy policy'
onDecideReceives the ids the visitor allowed. Wire this to what loads scripts.(allowed: string[]) => void—
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

cookie-consent.html
<!--
  Cookie Consent Banner — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<div class="relative min-h-[27rem] w-full ">
  <div role="dialog" aria-modal="false" aria-labelledby="_R_0_-cookie-consent-heading" class="absolute inset-x-0 bottom-0 z-10 p-4 sm:p-6">
    <div class="mx-auto max-w-3xl rounded-2xl border border-border/60 bg-card p-5 shadow-lg sm:p-6">
      <div class="flex gap-4">
        <div class="hidden h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background sm:flex">
          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cookie h-5 w-5 text-muted-foreground" aria-hidden="true">
            <path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"></path>
            <path d="M8.5 8.5v.01"></path>
            <path d="M16 15.5v.01"></path>
            <path d="M12 12v.01"></path>
            <path d="M11 17v.01"></path>
            <path d="M7 14v.01"></path>
          </svg>
        </div>
        <div class="min-w-0">
          <h2 id="_R_0_-cookie-consent-heading" class="font-semibold">Cookies, and what they are for</h2>
          <p class="mt-1.5 text-sm leading-relaxed text-muted-foreground">Essential cookies keep you signed in. The rest stay off until you turn them on, and you can change your mind at any time. <a href="/privacy" class="text-primary underline-offset-4 hover:underline">Privacy policy</a></p>
        </div>
      </div>
      <div class="mt-5 flex flex-col flex-wrap gap-2 sm:flex-row sm:justify-end">
        <button type="button" aria-expanded="false" aria-controls="_R_0_-cookie-consent-categories" class="rounded-xl px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground sm:me-auto">Choose what to allow</button>
        <button type="button" class="rounded-xl border border-border/60 bg-background px-4 py-2.5 text-sm font-semibold transition-colors hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">Reject non-essential</button>
        <button type="button" class="rounded-xl border border-border/60 bg-background px-4 py-2.5 text-sm font-semibold transition-colors hover:bg-muted/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">Accept all</button>
      </div>
    </div>
  </div>
</div>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Modals & Drawers

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Modals & Drawers

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Modals & Drawers blocks

View category
Open the full page for this block

Destructive Confirm Dialog

A type-to-confirm dialog built on native <dialog>, so the focus trap, top layer and escape handling come from the browser. Focus lands on Cancel, and the button says the verb.

Modals & Drawers184 lines1 dep
Open the full page for this block

Slide-Over Edit Panel

A side sheet for editing a record without losing the list behind it — native <dialog> for the trapping, a data attribute for the slide, and a sticky footer so Save never scrolls away.

Modals & Drawers208 lines1 dep
Open the full page for this block

Faceted Filter Drawer

Facets with per-option counts, zero-result options disabled rather than hidden so the list never jumps, and a pending selection applied on a button that says how many choices changed.

Modals & Drawers241 lines1 dep
Open the full page for this block

Share & Access Dialog

The share modal where a design mistake is a data leak: general access stated as its consequence rather than its setting, and a copy button that never silently widens access.

Modals & Drawers312 lines1 dep
Open the full page for this block

Mobile Bottom Sheet

Half and full snap points where the content owns the scroll and overscroll-contain keeps the page behind still. The drag handle is a real button, Escape and the backdrop close it, and safe-area padding keeps the last row pressable.

Modals & Drawers266 lines1 dep
Open the full page for this block

Unsaved Changes Prompt

The interception that keeps work: three answers rather than two, the changed fields listed by name, a destructive-styled discard, and Escape resolving to cancel.

Modals & Drawers237 lines1 dep
Open the full page for this block

Record Inspector Drawer

A non-modal drawer that keeps the list on screen, with previous and next controls for moving between records and an activity tab beside the fields.

Modals & Drawers336 lines1 dep