Cookie Consent
Overlays

Cookie Consent

Consent bar that slides up once on the drawer ease, offers equal-weight accept/necessary-only choices, and folds a per-category switch list open — opt-in by default, never re-nags.

Install

npx shadcn@latest add @paragon/cookie-consent

Also installs: button, switch

cookie-consent.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Cookie } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
import { Switch } from "@/registry/paragon/ui/switch";

export interface ConsentCategory {
  id: string;
  label: string;
  description?: string;
  /** Always on and not toggleable (e.g. strictly necessary cookies). */
  required?: boolean;
}

export interface CookieConsentProps {
  title?: string;
  description?: React.ReactNode;
  categories?: ConsentCategory[];
  /** ms after mount before the bar slides up. */
  delay?: number;
  /** Open the granular preferences on first render. */
  defaultExpanded?: boolean;
  onAcceptAll?: (prefs: Record<string, boolean>) => void;
  onAcceptNecessary?: (prefs: Record<string, boolean>) => void;
  onSave?: (prefs: Record<string, boolean>) => void;
  className?: string;
}

const defaultCategories: ConsentCategory[] = [
  {
    id: "necessary",
    label: "Strictly necessary",
    description: "Required for sign-in, security, and core features.",
    required: true,
  },
  {
    id: "analytics",
    label: "Analytics",
    description: "Anonymous usage data that helps us improve the product.",
  },
  {
    id: "marketing",
    label: "Marketing",
    description: "Personalised content and campaign measurement.",
  },
];

/**
 * Consent bar that behaves like furniture, not a siege: it slides up once on
 * the drawer ease, offers equal-weight Accept/Necessary-only choices, and
 * folds a granular per-category switch list open via grid rows. Non-required
 * categories start off — consent is opt-in. Any decision slides the bar away
 * for good (single mount, never re-nags); persistence is the caller's via
 * the three callbacks, each receiving the final preference map.
 */
export function CookieConsent({
  title = "We value your privacy",
  description = "We use cookies to keep you signed in, understand usage, and improve the product. You can change this anytime in Settings.",
  categories = defaultCategories,
  delay = 150,
  defaultExpanded = false,
  onAcceptAll,
  onAcceptNecessary,
  onSave,
  className,
}: CookieConsentProps) {
  const reducedMotion = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  const [decided, setDecided] = React.useState(false);
  const [expanded, setExpanded] = React.useState(defaultExpanded);
  const [prefs, setPrefs] = React.useState<Record<string, boolean>>(() =>
    Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required)])),
  );
  const prefsId = React.useId();

  React.useEffect(() => {
    const timer = setTimeout(() => setMounted(true), delay);
    return () => clearTimeout(timer);
  }, [delay]);

  const decide = (
    finalPrefs: Record<string, boolean>,
    callback?: (prefs: Record<string, boolean>) => void,
  ) => {
    setDecided(true);
    callback?.(finalPrefs);
  };

  const allOn = Object.fromEntries(categories.map((c) => [c.id, true]));
  const necessaryOnly = Object.fromEntries(
    categories.map((c) => [c.id, Boolean(c.required)]),
  );

  const drawerEase = [0.32, 0.72, 0, 1] as const;

  return (
    <AnimatePresence>
      {mounted && !decided && (
        <motion.section
          role="region"
          aria-label="Cookie preferences"
          initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: "120%" }}
          animate={{ opacity: 1, y: 0 }}
          exit={
            reducedMotion
              ? { opacity: 0, transition: { duration: 0.15 } }
              : {
                  opacity: 0,
                  y: "120%",
                  transition: { duration: 0.25, ease: drawerEase },
                }
          }
          transition={{ duration: 0.35, ease: drawerEase }}
          className={cn(
            "w-full max-w-xl rounded-xl bg-popover p-4 text-popover-foreground shadow-overlay",
            className,
          )}
        >
          <div className="flex items-start gap-3">
            <span
              aria-hidden
              className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground"
            >
              <Cookie className="size-4" />
            </span>
            <div className="min-w-0 flex-1">
              <p className="text-sm leading-5 font-medium">{title}</p>
              <p className="mt-1 text-[13px] leading-5 text-muted-foreground">
                {description}
              </p>
            </div>
          </div>

          <div
            id={prefsId}
            className={cn(
              "grid transition-[grid-template-rows,opacity] duration-250 ease-[var(--ease-out)] motion-reduce:transition-[opacity]",
              expanded
                ? "grid-rows-[1fr] opacity-100"
                : "grid-rows-[0fr] opacity-0",
            )}
          >
            <div
              className="overflow-hidden"
              inert={expanded ? undefined : true}
            >
              <ul className="mt-3 divide-y divide-border rounded-lg bg-card shadow-border">
                {categories.map((category) => (
                  <li
                    key={category.id}
                    className="flex items-center gap-3 px-3 py-2.5"
                  >
                    <div className="min-w-0 flex-1">
                      <p className="text-[13px] leading-4 font-medium">
                        {category.label}
                        {category.required && (
                          <span className="ml-1.5 text-[11px] font-normal text-muted-foreground">
                            Always on
                          </span>
                        )}
                      </p>
                      {category.description && (
                        <p className="mt-0.5 truncate text-xs text-muted-foreground">
                          {category.description}
                        </p>
                      )}
                    </div>
                    <Switch
                      aria-label={`Allow ${category.label.toLowerCase()} cookies`}
                      checked={category.required || prefs[category.id] === true}
                      disabled={category.required}
                      onCheckedChange={(checked) =>
                        setPrefs((current) => ({
                          ...current,
                          [category.id]: checked,
                        }))
                      }
                      className="shrink-0"
                    />
                  </li>
                ))}
              </ul>
            </div>
          </div>

          <div className="mt-3 flex flex-wrap items-center gap-2">
            <Button
              variant="ghost"
              size="sm"
              aria-expanded={expanded}
              aria-controls={prefsId}
              onClick={() => setExpanded((current) => !current)}
              className="text-muted-foreground hover:text-foreground"
            >
              Manage preferences
              <ChevronDown
                aria-hidden
                className={cn(
                  "transition-[rotate] duration-200 ease-[var(--ease-out)]",
                  expanded && "-rotate-180",
                )}
              />
            </Button>
            <div className="ml-auto flex gap-2">
              {expanded ? (
                <Button
                  variant="outline"
                  size="sm"
                  onClick={() => decide(prefs, onSave)}
                >
                  Save preferences
                </Button>
              ) : (
                <Button
                  variant="outline"
                  size="sm"
                  onClick={() => decide(necessaryOnly, onAcceptNecessary)}
                >
                  Necessary only
                </Button>
              )}
              <Button size="sm" onClick={() => decide(allOn, onAcceptAll)}>
                Accept all
              </Button>
            </div>
          </div>
        </motion.section>
      )}
    </AnimatePresence>
  );
}