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…

"use client"

import * as React from "react"
import { Cookie } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship inside the component via React 19 hoisted <style> — no
 * tailwind config edits, and duplicates dedupe by href. Entrance/exit are
 * plain CSS animations (not JS-driven transitions) so they autoplay the
 * instant the card mounts/unmounts — no extra "entered" state needed.
 */
const KEYFRAMES = `@keyframes cc-slide-in{from{transform:translateY(100%);opacity:0}to{transform:translateY(0);opacity:1}}
@keyframes cc-slide-out{from{transform:translateY(0);opacity:1}to{transform:translateY(100%);opacity:0}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cookie-consent.json

Prompt

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 localStorage is only ever read through useSyncExternalStore's getSnapshot, with a getServerSnapshot sentinel that's guaranteed to differ from any real stored value.
  • Non-blocking region, not a dialogrole="region" instead of role="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 @keyframes animations that autoplay on mount/unmount by themselves; no JS-managed "has it entered yet" state is needed to trigger them.
  • Reduced-motion instant appear/disappearmotion-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.

On This Page