Skip to content
6

Cookie Consent

Floating consent banner with per-category switches, accept, reject, and save actions, and a choice persisted in localStorage. Ships composable parts.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/cookie-consent.json

Code

components/blocks/cookie-consent.tsx
'use client';

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

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Field, FieldContent, FieldDescription, FieldLabel } from '@/registry/hirael/bases/radix/ui/field';
import { Switch } from '@/registry/hirael/bases/radix/ui/switch';

export type CookiePreferences = Record<string, boolean>;

interface CategoryRecord {
  id: string;
  required: boolean;
}

interface CookieConsentContextValue {
  prefs: CookiePreferences;
  setPref: (id: string, checked: boolean) => void;
  expanded: boolean;
  setExpanded: (expanded: boolean) => void;
  register: (category: CategoryRecord) => () => void;
  acceptAll: () => void;
  rejectAll: () => void;
  save: () => void;
}

const CookieConsentContext = React.createContext<CookieConsentContextValue | null>(null);

const useCookieConsent = () => {
  const ctx = React.useContext(CookieConsentContext);
  if (!ctx) {
    throw new Error('CookieConsent parts must be used within <CookieConsent>');
  }
  return ctx;
};

const noopUnsubscribe = () => () => {};

// Server render reports "chosen" so the SSR HTML never shows the banner and
// hydration agrees; the real storage value is read once hydration commits.
const useStoredChoice = (storageKey: string) => {
  const subscribe = React.useCallback(
    (cb: () => void) => {
      if (typeof window === 'undefined') return noopUnsubscribe();
      const handler = (e: StorageEvent) => {
        if (e.key === storageKey) cb();
      };
      window.addEventListener('storage', handler);
      return () => window.removeEventListener('storage', handler);
    },
    [storageKey],
  );
  const getSnapshot = React.useCallback(() => {
    if (typeof window === 'undefined') return true;
    try {
      return window.localStorage.getItem(storageKey) !== null;
    } catch {
      return false;
    }
  }, [storageKey]);
  const getServerSnapshot = React.useCallback(() => true, []);
  return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
};

const writeStored = (key: string, prefs: CookiePreferences) => {
  try {
    window.localStorage.setItem(key, JSON.stringify(prefs));
  } catch {
    // Storage may be unavailable (private mode, quota). The choice still
    // reaches `onChange`, so nothing else to do here.
  }
};

interface CookieConsentProps extends Omit<React.ComponentProps<'div'>, 'onChange'> {
  /** localStorage key the choice is saved under. */
  storageKey?: string;
  /** Whether the banner is pinned to the viewport or its nearest positioned parent. */
  position?: 'fixed' | 'absolute';
  /** Controlled visibility. When omitted, the banner shows until a choice is stored. */
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Starting values before the visitor decides. Required categories are always on. */
  defaultPreferences?: CookiePreferences;
  /** Called with the final preferences on Accept all, Reject all, or Save. */
  onChange?: (prefs: CookiePreferences) => void;
}

const CookieConsent = ({
  storageKey = 'cookie-consent',
  position = 'fixed',
  open: openProp,
  onOpenChange,
  defaultPreferences,
  onChange,
  className,
  children,
  ...props
}: CookieConsentProps) => {
  const isControlled = openProp !== undefined;
  const hasStoredChoice = useStoredChoice(storageKey);
  // Same-tab writes don't fire the storage event, so a local flag closes
  // the banner right after a choice is made.
  const [dismissed, setDismissed] = React.useState(false);
  const open = isControlled ? openProp : !hasStoredChoice && !dismissed;

  const [prefs, setPrefs] = React.useState<CookiePreferences>(() => defaultPreferences ?? {});
  const [expanded, setExpanded] = React.useState(false);
  const categoriesRef = React.useRef<Map<string, CategoryRecord>>(new Map());

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (!isControlled) setDismissed(!next);
      onOpenChange?.(next);
    },
    [isControlled, onOpenChange],
  );

  const register = React.useCallback((category: CategoryRecord) => {
    categoriesRef.current.set(category.id, category);
    return () => {
      categoriesRef.current.delete(category.id);
    };
  }, []);

  const setPref = React.useCallback((id: string, checked: boolean) => {
    setPrefs((prev) => ({ ...prev, [id]: checked }));
  }, []);

  const commit = React.useCallback(
    (next: CookiePreferences) => {
      writeStored(storageKey, next);
      setPrefs(next);
      onChange?.(next);
      setExpanded(false);
      setOpen(false);
    },
    [storageKey, onChange, setOpen],
  );

  const acceptAll = React.useCallback(() => {
    const next: CookiePreferences = {};
    for (const { id } of categoriesRef.current.values()) next[id] = true;
    commit(next);
  }, [commit]);

  const rejectAll = React.useCallback(() => {
    const next: CookiePreferences = {};
    for (const { id, required } of categoriesRef.current.values()) {
      next[id] = required;
    }
    commit(next);
  }, [commit]);

  const save = React.useCallback(() => {
    const next: CookiePreferences = {};
    for (const { id, required } of categoriesRef.current.values()) {
      next[id] = required || Boolean(prefs[id]);
    }
    commit(next);
  }, [commit, prefs]);

  const value = React.useMemo<CookieConsentContextValue>(
    () => ({
      prefs,
      setPref,
      expanded,
      setExpanded,
      register,
      acceptAll,
      rejectAll,
      save,
    }),
    [prefs, setPref, expanded, register, acceptAll, rejectAll, save],
  );

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === 'Escape' && expanded) {
      event.stopPropagation();
      setExpanded(false);
    }
  };

  if (!open) return null;

  return (
    <CookieConsentContext.Provider value={value}>
      <div
        role="dialog"
        aria-modal={false}
        aria-label="Cookie preferences"
        data-slot="cookie-consent"
        data-position={position}
        data-expanded={expanded ? '' : undefined}
        onKeyDown={onKeyDown}
        className={cn(
          position === 'fixed' ? 'fixed' : 'absolute',
          'bottom-4 start-4 z-50 flex w-[calc(100%-2rem)] max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg',
          'animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none',
          className,
        )}
        {...props}
      >
        {children}
      </div>
    </CookieConsentContext.Provider>
  );
};

type CookieConsentTitleProps = React.ComponentProps<'h2'>;

const CookieConsentTitle = ({ className, children, ...props }: CookieConsentTitleProps) => {
  return (
    <h2
      data-slot="cookie-consent-title"
      className={cn('flex items-center gap-2 text-sm font-semibold tracking-[-0.01em] text-foreground', className)}
      {...props}
    >
      <Cookie aria-hidden className="size-4 text-muted-foreground" />
      {children}
    </h2>
  );
};

type CookieConsentDescriptionProps = React.ComponentProps<'p'>;

const CookieConsentDescription = ({ className, ...props }: CookieConsentDescriptionProps) => {
  return (
    <p
      data-slot="cookie-consent-description"
      className={cn('text-sm leading-relaxed text-muted-foreground', className)}
      {...props}
    />
  );
};

type CookieConsentManageProps = React.ComponentProps<typeof Button>;

/** Expands the per-category switches. Hidden once they're showing. */
const CookieConsentManage = ({ className, children = 'Manage', ...props }: CookieConsentManageProps) => {
  const { expanded, setExpanded } = useCookieConsent();
  if (expanded) return null;
  return (
    <Button
      type="button"
      variant="ghost"
      size="sm"
      data-slot="cookie-consent-manage"
      aria-expanded={expanded}
      onClick={() => setExpanded(true)}
      className={cn('text-muted-foreground', className)}
      {...props}
    >
      {children}
    </Button>
  );
};

type CookieConsentCategoriesProps = React.ComponentProps<'div'>;

/** Renders its categories only while the banner is expanded. */
const CookieConsentCategories = ({ className, ...props }: CookieConsentCategoriesProps) => {
  const { expanded } = useCookieConsent();
  if (!expanded) return null;
  return (
    <div
      data-slot="cookie-consent-categories"
      className={cn('flex flex-col divide-y divide-border rounded-md border border-border bg-background', className)}
      {...props}
    />
  );
};

interface CookieConsentCategoryProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  /** Key the choice is stored under, e.g. "analytics". */
  id: string;
  name: React.ReactNode;
  description?: React.ReactNode;
  /** Always on and cannot be switched off. */
  required?: boolean;
}

const CookieConsentCategory = ({
  id,
  name,
  description,
  required = false,
  className,
  ...props
}: CookieConsentCategoryProps) => {
  const { prefs, setPref, register } = useCookieConsent();

  React.useEffect(() => register({ id, required }), [register, id, required]);

  const checked = required || Boolean(prefs[id]);
  const switchId = `cookie-consent-${id}`;

  return (
    <Field
      orientation="horizontal"
      data-slot="cookie-consent-category"
      data-required={required ? '' : undefined}
      className={cn('justify-between gap-4 p-3', className)}
      {...props}
    >
      <FieldContent className="min-w-0 gap-0.5">
        <FieldLabel htmlFor={switchId} className="items-center">
          {name}
          {required ? (
            <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Always on</span>
          ) : null}
        </FieldLabel>
        {description ? <FieldDescription className="text-xs">{description}</FieldDescription> : null}
      </FieldContent>
      <Switch
        id={switchId}
        size="sm"
        checked={checked}
        disabled={required}
        onCheckedChange={(next) => setPref(id, next)}
        className="mt-0.5"
      />
    </Field>
  );
};

type CookieConsentActionsProps = React.ComponentProps<'div'>;

const CookieConsentActions = ({ className, ...props }: CookieConsentActionsProps) => {
  return (
    <div data-slot="cookie-consent-actions" className={cn('flex flex-wrap items-center gap-2', className)} {...props} />
  );
};

type CookieConsentButtonProps = React.ComponentProps<typeof Button>;

const CookieConsentAcceptAll = ({ children = 'Accept all', ...props }: CookieConsentButtonProps) => {
  const { acceptAll } = useCookieConsent();
  return (
    <Button type="button" size="sm" data-slot="cookie-consent-accept-all" onClick={acceptAll} {...props}>
      {children}
    </Button>
  );
};

const CookieConsentRejectAll = ({ children = 'Reject all', ...props }: CookieConsentButtonProps) => {
  const { rejectAll } = useCookieConsent();
  return (
    <Button
      type="button"
      variant="outline"
      size="sm"
      data-slot="cookie-consent-reject-all"
      onClick={rejectAll}
      {...props}
    >
      {children}
    </Button>
  );
};

/** Saves the current switches. Only shown while categories are expanded. */
const CookieConsentSave = ({ children = 'Save choices', ...props }: CookieConsentButtonProps) => {
  const { expanded, save } = useCookieConsent();
  if (!expanded) return null;
  return (
    <Button type="button" variant="outline" size="sm" data-slot="cookie-consent-save" onClick={save} {...props}>
      {children}
    </Button>
  );
};

type CookieConsentLinkProps = React.ComponentProps<'a'>;

const CookieConsentLink = ({ className, ...props }: CookieConsentLinkProps) => {
  return (
    <a
      data-slot="cookie-consent-link"
      className={cn(
        'text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
        className,
      )}
      {...props}
    />
  );
};

export {
  CookieConsent,
  CookieConsentTitle,
  CookieConsentDescription,
  CookieConsentManage,
  CookieConsentCategories,
  CookieConsentCategory,
  CookieConsentActions,
  CookieConsentAcceptAll,
  CookieConsentRejectAll,
  CookieConsentSave,
  CookieConsentLink,
  useCookieConsent,
};

const PREVIEW_STORAGE_KEY = 'hirael-cookie-consent-preview';

const CookieConsentBlock = () => {
  const [open, setOpen] = React.useState(true);
  const [choice, setChoice] = React.useState<CookiePreferences | null>(null);

  const reset = () => {
    try {
      window.localStorage.removeItem(PREVIEW_STORAGE_KEY);
    } catch {
      // ignore
    }
    setChoice(null);
    setOpen(true);
  };

  const summary = choice
    ? Object.entries(choice)
        .filter(([, on]) => on)
        .map(([id]) => id)
        .join(', ')
    : null;

  return (
    <section
      data-slot="cookie-consent-block"
      className="relative flex min-h-svh w-full flex-col items-center justify-center gap-3 overflow-hidden bg-background p-6 sm:p-10"
    >
      <div className="flex flex-col items-center gap-2 text-center">
        <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
          {summary ? `Saved · ${summary}` : 'Waiting for a choice'}
        </span>
        {!open ? (
          <Button type="button" variant="link" onClick={reset} className="h-auto p-0 underline">
            Reset and show the banner again
          </Button>
        ) : null}
      </div>

      <CookieConsent
        position="absolute"
        storageKey={PREVIEW_STORAGE_KEY}
        open={open}
        onOpenChange={setOpen}
        onChange={setChoice}
      >
        <div className="flex flex-col gap-1.5">
          <CookieConsentTitle>We use cookies</CookieConsentTitle>
          <CookieConsentDescription>
            Necessary cookies keep the site working. With your permission we also use a few for analytics and to measure
            campaigns. <CookieConsentLink href="#">Privacy policy</CookieConsentLink>
          </CookieConsentDescription>
        </div>

        <CookieConsentCategories>
          <CookieConsentCategory
            id="necessary"
            name="Necessary"
            description="Sign-in, security, and remembering this choice."
            required
          />
          <CookieConsentCategory
            id="analytics"
            name="Analytics"
            description="Page views and feature usage, aggregated."
          />
          <CookieConsentCategory
            id="marketing"
            name="Marketing"
            description="Attribution for the campaigns that brought you here."
          />
        </CookieConsentCategories>

        <CookieConsentActions>
          <CookieConsentAcceptAll />
          <CookieConsentRejectAll />
          <CookieConsentSave />
          <CookieConsentManage className="ms-auto" />
        </CookieConsentActions>
      </CookieConsent>
    </section>
  );
};

export default CookieConsentBlock;

Dependencies

shadcn registry

buttonfieldswitch

npm

lucide-react

Shipped