Feedback
Cookie Consent
A bottom-fixed cookie consent bar that persists the visitor's accept/decline choice to localStorage and never re-appears once decided.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cookie-consent.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CookieConsent" component (lucide-react
for the icon; no other runtime dependencies).
Contract
- Export a forwardRef<HTMLDivElement, CookieConsentProps> rendering a fixed
bottom card. Props: onAccept: () => void (required — the only guaranteed
action); onDecline?: () => void (omit to render an Accept-only card, no
Decline button); storageKey?: string (default "cookie-consent", the
localStorage key the choice is persisted under as the literal string
"accepted" or "declined"); policyHref?: string (renders a real <a> to the
privacy policy when provided); children?: ReactNode (custom copy; default
is one neutral sentence). className merged via cn(), rest props spread on
the root div.
Behavior
- On mount, read the stored choice. If a choice already exists, render
nothing (the visitor already decided, previous sessions included). If none
exists, slide the card in from the bottom.
- SSR/hydration safety: never read localStorage directly during render — the
purity rule forbids impure reads there. Read it through
useSyncExternalStore instead: getSnapshot reads localStorage.getItem
(wrapped in try/catch, since private-mode/quota-restricted browsers can
throw on access) and returns one of "accepted" | "declined" | "none";
getServerSnapshot always returns a distinct "pending" sentinel. Server and
the client's first paint agree on "pending" (render nothing yet); once
React re-checks the snapshot after hydration it sees the real value. A
one-shot ref guard then decides, during render (not inside an effect —
React's own "adjust state" pattern), whether to reveal the card: only the
first time the snapshot resolves away from "pending", and only if it
resolved to "none".
- Clicking Accept or Decline: write the choice to localStorage (swallow
write errors — quota exceeded / storage disabled must not throw), call the
matching callback, then play the exit animation and unmount on
animationend. If prefers-reduced-motion is on, skip the exit animation and
unmount immediately instead — motion-reduce means the whole card must
still disappear, just without a played transition.
- role="region" aria-label="Cookie consent" — deliberately not role="dialog".
A consent bar should not steal focus or block the rest of the page the
way a modal does; both buttons stay reachable by normal tab order like any
other in-page control.
Rendering & styling
- Semantic tokens only: bg-card/text-card-foreground/border/shadow-lg for
the card, bg-primary/text-primary-foreground for Accept, border-input/
bg-background for the Decline outline button, text-muted-foreground for
the policy link. cn() merges the caller's className (useful for demos that
need to swap "fixed" for "absolute" inside a contained preview box).
- Enter/exit are plain CSS @keyframes (shipped via a hoisted <style> tag),
not a JS-driven transition — the animation autoplays the instant the
element is inserted/removed from the DOM, so no extra "entered" state is
needed just to trigger it. motion-reduce:[animation:none] on both
directions makes the card appear/disappear instantly instead of sliding.
Customization levers
- Position: this is the floating-card variant (bottom-right on desktop,
full-width on mobile). A full-width bottom bar variant just drops the
sm:right-4 sm:max-w-md constraints and stretches the card edge-to-edge at
every breakpoint.
- Two buttons → three: add a "Manage preferences" button that opens a
Dialog/Sheet with per-category toggles (analytics, marketing, ...) and
persist a small object instead of a single string.
- Stale re-ask: extend the persisted value to { choice, decidedAt } and
treat an entry older than N months as "none" again, so long-lived
policies periodically re-confirm consent instead of remembering forever.
- Hard compliance wall: if the product needs a blocking gate that requires a
choice before anything else is usable, this is the wrong primitive — swap
the region for a Dialog with a focus trap and no dismiss-by-outside-click,
which is a deliberately different, more intrusive contract than this
component's.Concepts
- Consent persistence — the choice is written as a plain string ("accepted"/"declined") under a caller-chosen
storageKey; once present, every future mount — including a hard page refresh — sees it and renders nothing. - SSR-safe storage read — the first client paint must match the server's, so
localStorageis only ever read throughuseSyncExternalStore'sgetSnapshot, with agetServerSnapshotsentinel that's guaranteed to differ from any real stored value. - Non-blocking region, not a dialog —
role="region"instead ofrole="dialog"is the deliberate accessibility trade-off: a compliance bar shouldn't trap focus or block the rest of the page, unlike a modal that genuinely needs an answer before anything else happens. - CSS-only enter/exit — entrance and exit are
@keyframesanimations that autoplay on mount/unmount by themselves; no JS-managed "has it entered yet" state is needed to trigger them. - Reduced-motion instant appear/disappear —
motion-reduce:[animation:none]doesn't just shorten the animation, it removes it entirely, so the card still shows and hides correctly for a visitor who has asked for no motion — it just does so instantly.