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.
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.
components/cookie-consent.tsxnpx hoverlab add cookie-consent
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
Essential cookies keep you signed in. The rest stay off until you turn them on, and you can change your mind at any time. Privacy policy
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'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>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/cookie-consent.tsx and import it where you need the section:
import { CookieConsent } from '@/components/cookie-consent'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.
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.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Cookies, and what they are for' |
body | React.ReactNode | — |
categories | ConsentCategory[] | DEFAULT_CATEGORIES |
policyHref | string | '/privacy' |
policyLabel | string | 'Privacy policy' |
onDecideReceives the ids the visitor allowed. Wire this to what loads scripts. | (allowed: string[]) => void | — |
className | string | '' |
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 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>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 & DrawersCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
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.
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.
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.
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.
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.
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.
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.