Skip to content
Component

Cookie consent

A consent banner with per-category cookie preferences that can be reopened.

  • #arc
  • #component
  • #privacy
  • #consent
  • #banner
  • #preferences

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/cookie-consent.json

cookie-consent.module.csscss
/* The layer spans the bottom edge and lets clicks through; only the surface takes pointer input. */
.layer { position: fixed; z-index: 60; right: 16px; bottom: 16px; left: 16px; height: 0; pointer-events: none; font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.absolute { position: absolute; z-index: 5; }
/* While a modal (a drawer, dialog or sheet) is open the page behind it is locked; the layer would float above the modal's own content (it is the
   topmost layer), so it steps aside until the modal closes. */
.layer { transition: opacity var(--duration-fast) var(--ease-standard), visibility 0s linear 0s; }
:global(body[data-scroll-locked]) .layer:not(.absolute) { opacity: 0; visibility: hidden; transition: opacity var(--duration-fast) var(--ease-standard), visibility 0s linear var(--duration-fast); }
@media (prefers-reduced-motion: reduce) { .layer { transition: none; } }

/* One surface whose position, size, and corner spring between the bar, the panel, and the reopen button. */
.surface { position: absolute; bottom: 0; left: 0; overflow: hidden; background: var(--surface-raised); box-shadow: 0 0 0 1px var(--surface-raised), var(--shadow-floating); color: var(--foreground); pointer-events: auto; transform-origin: bottom center; will-change: transform, width, height; }

/* Faces sit on the bottom edge at their final width, so the surface grows upward into them. */
.face { position: absolute; bottom: 0; left: 0; box-sizing: border-box; }
.face[data-side="center"] { left: 50%; translate: -50% 0; }
.face[data-side="right"] { right: 0; left: auto; }
.face[inert] { pointer-events: none; }

.bar { container-type: inline-size; padding: 10px 10px 10px 18px; }
.barInner { display: flex; align-items: center; gap: var(--space-4); }
.barInner[data-stack] { flex-direction: column; align-items: stretch; gap: var(--space-3); padding: 6px 0 0; }
.message { flex: 1 1 auto; min-width: 0; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
.barActions { display: flex; flex: none; align-items: center; gap: 6px; }
.barInner[data-stack] .barActions { display: grid; grid-template-columns: auto 1fr 1fr; }
.barInner[data-stack] { padding-right: 8px; }

.policy { padding: 0; border: 0; background: none; color: var(--foreground); cursor: pointer; font: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) var(--ease-standard); }

.panel { container-type: inline-size; display: grid; gap: var(--space-3); padding: 18px; }
.panelHead { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-block: -4px -6px; }
.title { margin: 0; font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }
.iconButton { display: inline-grid; width: 32px; height: 32px; flex: none; place-items: center; margin-right: -8px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
.iconButton:active { background: var(--surface-muted); color: var(--foreground); }

.list { display: grid; max-height: var(--cookie-consent-list-max, min(46vh, 300px)); margin: 0 -6px; padding: 0; overflow-y: auto; list-style: none; overscroll-behavior: contain; scrollbar-width: thin; }
.row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 10px 6px; border-top: 1px solid var(--border-subtle); }
.row:first-child { border-top: 0; }
.rowText { display: grid; min-width: 0; gap: 2px; }
.rowLabel { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.rowLabel[for] { cursor: pointer; }
.rowHint { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.always { flex: none; color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }

.panelActions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding-top: var(--space-2); border-top: 1px solid var(--border-subtle); }
.save { grid-column: 1 / -1; }

.chipFace { height: 44px; }
.chip { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }
.chip:active { background: var(--surface-muted); }

.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

@media (hover: hover) and (pointer: fine) {
  .policy:hover { text-decoration-color: currentColor; }
  .iconButton:hover { background: var(--surface-muted); color: var(--foreground); }
  .chip:hover { background: var(--surface-muted); }
}

/* Below about 340px the three bar buttons no longer fit one row: refusing and accepting share it, and Customize takes the row under them. */
@container (max-width: 300px) {
  .barInner[data-stack] .barActions { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .barInner[data-stack] .barActions > :first-child { grid-column: 1 / -1; order: 1; }
}

@container (min-width: 340px) {
  .panelActions { grid-template-columns: auto auto 1fr; }
  .save { grid-column: auto; }
}

@media (prefers-reduced-motion: reduce) {
  .policy, .iconButton, .chip { transition: none; }
}
cookie-consent.tsxtsx
"use client";

import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CookieIcon } from "@hugeicons/core-free-icons";
import Button from "../button/button";
import { Switch } from "../switch/switch";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./cookie-consent.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";

/** One group of cookies a visitor can allow or refuse. */
export interface CookieCategory {
  id: string;
  label: string;
  description?: string;
  /** Needed for the site to work. Always on and cannot be switched off. */
  required?: boolean;
  /** Starting position of the switch before a choice is saved. Non-essential categories default to off. */
  defaultEnabled?: boolean;
}

/** Category id to allowed. */
export type CookieConsentValue = Record<string, boolean>;
export type CookieConsentAction = "accept-all" | "reject-all" | "save" | "reset";

/** What is written to storage under `storageKey`. */
export interface StoredCookieConsent {
  value: CookieConsentValue;
  version: string;
  updatedAt: string;
}

export interface CookieConsentHandle {
  /** Opens the preferences panel, for example from a "Cookie settings" footer link. */
  openPreferences: () => void;
  /** Closes the preferences panel without saving. */
  closePreferences: () => void;
  /** Forgets the saved choice and asks again. */
  reset: () => void;
}

export interface CookieConsentLabels {
  message: ReactNode;
  customize: string;
  acceptAll: string;
  rejectAll: string;
  save: string;
  preferencesTitle: string;
  preferencesDescription: ReactNode;
  reopen: string;
  alwaysOn: string;
  close: string;
}

/**
 * A consent bar that morphs into a preferences panel and, once a choice is saved, folds into a small floating button that
 * reopens it. One surface springs between all three shapes. Non-essential categories start off, refusing is as easy as
 * accepting, and the choice is reported through `onChange` and can be remembered in `localStorage`.
 */
export interface CookieConsentProps {
  categories?: CookieCategory[];
  /** The saved choice. `null` means the visitor has not decided yet. Leave it out to let the component manage it. */
  value?: CookieConsentValue | null;
  defaultValue?: CookieConsentValue | null;
  onChange?: (value: CookieConsentValue | null, detail: { action: CookieConsentAction; updatedAt: Date }) => void;
  /** Remembers the choice in `localStorage` under this key. Read it elsewhere with `readCookieConsent`. */
  storageKey?: string;
  /** Bump when your categories or policy change to ask again. */
  version?: string;
  /** Whether the preferences panel is open. */
  preferencesOpen?: boolean;
  defaultPreferencesOpen?: boolean;
  onPreferencesOpenChange?: (open: boolean) => void;
  /** Link to the full policy, shown in the bar and panel. */
  policy?: { label: string; href?: string; onClick?: () => void };
  position?: "bottom" | "bottom-left" | "bottom-right";
  /** `fixed` pins to the viewport; `absolute` fills the nearest positioned ancestor, useful for previews. */
  strategy?: "fixed" | "absolute";
  /** Keep a small button after a choice so visitors can change their mind. Defaults to true. */
  showReopenButton?: boolean;
  /** Corner for the reopen button. Defaults to the bar's side, or left when the bar is centered. */
  reopenSide?: "left" | "right";
  labels?: Partial<CookieConsentLabels>;
  className?: string;
}

export const defaultCookieCategories: CookieCategory[] = [
  { id: "necessary", label: "꼭 필요한 쿠키", description: "로그인, 보안, 장바구니에 쓰여요. 이 쿠키가 없으면 사이트가 동작하지 않아요.", required: true },
  { id: "preferences", label: "설정 쿠키", description: "언어, 지역, 화면 설정을 기억해요." },
  { id: "analytics", label: "분석 쿠키", description: "방문 수와 도움이 된 페이지를 통계로만 살펴봐요." },
  { id: "marketing", label: "마케팅 쿠키", description: "캠페인 성과를 재고 다른 사이트에 알맞은 광고를 보여 줘요." },
];

const defaultLabels: CookieConsentLabels = {
  message: "사이트를 운영하려고 꼭 필요한 쿠키를 써요. 동의해 주시면 설정을 기억하고 도움이 되는 점을 알아보는 선택 쿠키도 써요.",
  customize: "직접 고르기",
  acceptAll: "모두 허용하기",
  rejectAll: "모두 거부하기",
  save: "선택 저장하기",
  preferencesTitle: "쿠키 설정",
  preferencesDescription: "사용할 선택 쿠키를 골라 주세요. 언제든 바꿀 수 있어요.",
  reopen: "쿠키 설정",
  alwaysOn: "항상 켜짐",
  close: "설정 닫기",
};

/** Reads a saved choice, or `null` when there is none or it was saved for another version. Safe to call on the server. */
export function readCookieConsent(storageKey: string, version = "1"): StoredCookieConsent | null {
  if (typeof window === "undefined") return null;
  try {
    const raw = window.localStorage.getItem(storageKey);
    if (!raw) return null;
    const parsed = JSON.parse(raw) as Partial<StoredCookieConsent>;
    if (!parsed || typeof parsed.value !== "object" || parsed.value === null || parsed.version !== version) return null;
    return { value: parsed.value, version: parsed.version, updatedAt: String(parsed.updatedAt ?? "") };
  } catch {
    return null;
  }
}

function writeCookieConsent(storageKey: string, stored: StoredCookieConsent | null) {
  try {
    if (stored) window.localStorage.setItem(storageKey, JSON.stringify(stored));
    else window.localStorage.removeItem(storageKey);
  } catch { /* Storage can be blocked; the callback still reports the choice. */ }
}

type Mode = "bar" | "panel" | "chip";
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps its velocity. */
const physical = (visualDuration: number, bounce: number): Transition => {
  const root = 2 * Math.PI / (visualDuration * 1.2);
  return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const GROW = physical(.46, .14), SHRINK = physical(.38, 0);
const BAR_MAX = 660, PANEL_MAX = 420, CHIP = 44, STACK_BELOW = 560;

const subscribe = () => () => {};
function useHydrated() { return useSyncExternalStore(subscribe, () => true, () => false); }

const faceVariants: Variants = {
  hidden: { opacity: 0, y: 10, filter: `blur(${motionTokens.blur.soft}px)` },
  shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { y: physical(.42, .08), opacity: { duration: motionTokens.duration.standard, ease: enter, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.moderate, ease: enter, delay: motionTokens.delay.micro } } },
  gone: { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
};
const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.fast } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };

/** A face reports its natural height while it is current, then goes inert as it leaves. It is pinned to the side the surface is placed on, so a
 *  centered bar and panel cross-fade in place instead of the incoming face sliding in from the surface's left edge. */
function Face({ id, width, side, reduced, focusOnMount, onSize, className, children, ...rest }: { id: Mode; width: number; side: "left" | "right" | "center"; reduced: boolean; focusOnMount: boolean; onSize: (id: Mode, height: number) => void; className?: string; children: ReactNode; role?: string; "aria-modal"?: boolean; "aria-labelledby"?: string; "aria-describedby"?: string; "aria-label"?: string; onKeyDown?: (event: ReactKeyboardEvent<HTMLDivElement>) => void }) {
  const ref = useRef<HTMLDivElement>(null);
  const present = useIsPresent();
  useLayoutEffect(() => {
    const node = ref.current;
    if (!node || !present) return;
    const report = () => onSize(id, node.offsetHeight);
    report();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(report);
    observer.observe(node);
    return () => observer.disconnect();
  }, [id, onSize, present]);
  useEffect(() => {
    if (!focusOnMount) return;
    ref.current?.querySelector<HTMLElement>("[data-autofocus]")?.focus({ preventScroll: true });
  }, [focusOnMount]);
  return <motion.div {...rest} ref={ref} className={[styles.face, className].filter(Boolean).join(" ")} style={{ width }} data-face={id} data-side={side}
    variants={reduced ? fadeVariants : faceVariants} initial="hidden" animate="shown" exit="gone" inert={!present || undefined}>{children}</motion.div>;
}

export const CookieConsent = forwardRef<CookieConsentHandle, CookieConsentProps>(function CookieConsent({
  categories = defaultCookieCategories, value, defaultValue = null, onChange, storageKey, version = "1",
  preferencesOpen, defaultPreferencesOpen = false, onPreferencesOpenChange, policy, position = "bottom", strategy = "fixed",
  showReopenButton = true, reopenSide, labels: labelOverrides, className,
}, ref) {
  const labels = { ...defaultLabels, ...labelOverrides };
  const reduced = !!useReducedMotion();
  const hydrated = useHydrated();
  const uid = useId();
  const titleId = `${uid}-title`, descriptionId = `${uid}-description`, messageId = `${uid}-message`;

  /* The saved choice: controlled, or internal and seeded from storage once on the client. */
  const [internal, setInternal] = useState<CookieConsentValue | null>(defaultValue);
  const [loaded, setLoaded] = useState(!storageKey || value !== undefined);
  useEffect(() => {
    if (loaded || !storageKey) return;
    const stored = readCookieConsent(storageKey, version);
    // Storage is only readable after hydration; this runs once to seed the saved choice.
    // eslint-disable-next-line react-hooks/set-state-in-effect
    if (stored) setInternal(stored.value);
    setLoaded(true);
  }, [loaded, storageKey, version]);
  const consent = value !== undefined ? value : internal;
  const decided = consent != null;

  const [panelInternal, setPanelInternal] = useState(defaultPreferencesOpen);
  const panelOpen = preferencesOpen ?? panelInternal;
  const setPanel = useCallback((next: boolean) => {
    if (preferencesOpen === undefined) setPanelInternal(next);
    onPreferencesOpenChange?.(next);
  }, [onPreferencesOpenChange, preferencesOpen]);

  const baseline = useCallback((from: CookieConsentValue | null) => Object.fromEntries(categories.map(category => [category.id, category.required ? true : from?.[category.id] ?? category.defaultEnabled ?? false])), [categories]);
  const [draft, setDraft] = useState<CookieConsentValue>(() => baseline(consent));
  const [focusTarget, setFocusTarget] = useState<Mode | null>(null);

  const mode: Mode | null = !hydrated || !loaded ? null : panelOpen ? "panel" : !decided ? "bar" : showReopenButton ? "chip" : null;

  const openPanel = useCallback((focus = true) => {
    setDraft(baseline(consent));
    setFocusTarget(focus ? "panel" : null);
    setPanel(true);
  }, [baseline, consent, setPanel]);

  const closePanel = useCallback((focus = true) => {
    setFocusTarget(focus ? (decided ? "chip" : "bar") : null);
    setPanel(false);
  }, [decided, setPanel]);

  const commit = useCallback((next: CookieConsentValue | null, action: CookieConsentAction) => {
    const updatedAt = new Date();
    if (value === undefined) setInternal(next);
    if (storageKey) writeCookieConsent(storageKey, next ? { value: next, version, updatedAt: updatedAt.toISOString() } : null);
    onChange?.(next, { action, updatedAt });
  }, [onChange, storageKey, value, version]);

  const choose = (action: "accept-all" | "reject-all" | "save") => {
    const next = action === "save" ? { ...draft } : Object.fromEntries(categories.map(category => [category.id, category.required ? true : action === "accept-all"]));
    // Keep focus on the page: the choice folds the surface into its reopen button, which takes focus when it exists.
    setFocusTarget(showReopenButton ? "chip" : null);
    commit(next, action);
    if (panelOpen) setPanel(false);
  };

  useImperativeHandle(ref, () => ({
    openPreferences: () => openPanel(true),
    closePreferences: () => closePanel(false),
    reset: () => { setPanel(false); setFocusTarget(null); commit(null, "reset"); },
  }), [closePanel, commit, openPanel, setPanel]);

  /* Geometry. The layer is measured; each face gets an explicit width, reports its height, and the surface springs to it. */
  const layerRef = useRef<HTMLDivElement>(null);
  const [layerWidth, setLayerWidth] = useState(0);
  useLayoutEffect(() => {
    const node = layerRef.current;
    if (!node) return;
    const read = () => setLayerWidth(node.clientWidth);
    read();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(read);
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  const align = position === "bottom-left" ? "left" : position === "bottom-right" ? "right" : "center";
  const chipSide = reopenSide ?? (align === "right" ? "right" : "left");
  const widths = { bar: Math.min(layerWidth, BAR_MAX), panel: Math.min(layerWidth, PANEL_MAX), chip: CHIP };
  const place = (width: number, side: "left" | "right" | "center") => side === "left" ? 0 : side === "right" ? layerWidth - width : (layerWidth - width) / 2;
  // Reopened preferences grow out of the corner button, so the panel opens on its side once a choice exists.
  const panelSide = decided && showReopenButton ? chipSide : align;

  const x = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0), radius = useMotionValue(22);
  const heights = useRef<Record<Mode, number>>({ bar: 0, panel: 0, chip: CHIP });
  const shape = useRef({ w: 0, h: 0, ready: false });
  const [tick, setTick] = useState(0);
  const onSize = useCallback((id: Mode, h: number) => {
    if (heights.current[id] === h) return;
    heights.current[id] = h;
    setTick(count => count + 1);
  }, []);

  useLayoutEffect(() => {
    if (!mode || !layerWidth) { shape.current.ready = false; return; }
    const w = widths[mode], h = mode === "chip" ? CHIP : heights.current[mode];
    if (!h) return;
    const next = { x: place(w, mode === "chip" ? chipSide : mode === "panel" ? panelSide : align), w, h, r: mode === "chip" ? CHIP / 2 : mode === "panel" ? 26 : 22 };
    if (!shape.current.ready || reduced) {
      x.jump(next.x); width.jump(next.w); height.jump(next.h); radius.jump(next.r);
      shape.current = { w: next.w, h: next.h, ready: true };
      return;
    }
    const transition = next.w * next.h >= shape.current.w * shape.current.h ? GROW : SHRINK;
    shape.current = { w: next.w, h: next.h, ready: true };
    animate(x, next.x, transition); animate(width, next.w, transition); animate(height, next.h, transition); animate(radius, next.r, transition);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mode, layerWidth, tick, reduced, align, chipSide, panelSide]);

  const onPanelKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "Escape") return;
    event.stopPropagation();
    playUiSound("close");
    closePanel(true);
  };

  const policyLink = policy ? policy.href
    ? <a className={styles.policy} href={policy.href} onClick={policy.onClick}>{policy.label}</a>
    : <button type="button" className={styles.policy} onClick={() => { playUiSound("click"); policy.onClick?.(); }}>{policy.label}</button> : null;
  const optional = categories.filter(category => !category.required);
  const allowedCount = optional.filter(category => draft[category.id]).length;

  return <div ref={layerRef} className={[styles.layer, strategy === "absolute" ? styles.absolute : "", className].filter(Boolean).join(" ")} data-position={position}>
    <AnimatePresence>
      {mode ? <motion.div key="surface" className={styles.surface} style={{ x, width, height, borderRadius: radius }}
        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: .98 }}
        animate={{ opacity: 1, y: 0, scale: 1, transition: reduced ? { duration: motionTokens.duration.fast } : { y: physical(.5, .1), scale: physical(.5, .1), opacity: { duration: motionTokens.duration.standard, ease: enter } } }}
        exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, y: 12, scale: .96, transition: { duration: motionTokens.duration.exit, ease: standard } }}>
        <AnimatePresence initial={false}>
          {mode === "bar" ? <Face key="bar" id="bar" width={widths.bar} side={align} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === "bar"}
            className={styles.bar} role="dialog" aria-modal={false} aria-label={labels.preferencesTitle} aria-describedby={messageId}>
            <div className={styles.barInner} data-stack={widths.bar < STACK_BELOW || undefined}>
              <p id={messageId} className={styles.message}>{labels.message}{policyLink ? <> {policyLink}</> : null}</p>
              <div className={styles.barActions}>
                <Button size="sm" variant="ghost" silent data-autofocus onClick={() => { playUiSound("open"); openPanel(true); }}>{labels.customize}</Button>
                <Button size="sm" variant="secondary" onClick={() => choose("reject-all")}>{labels.rejectAll}</Button>
                <Button size="sm" variant="secondary" onClick={() => choose("accept-all")}>{labels.acceptAll}</Button>
              </div>
            </div>
          </Face> : null}

          {mode === "panel" ? <Face key="panel" id="panel" width={widths.panel} side={panelSide} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === "panel"}
            className={styles.panel} role="dialog" aria-modal={false} aria-labelledby={titleId} aria-describedby={descriptionId} onKeyDown={onPanelKeyDown}>
            <div className={styles.panelHead}>
              <h2 id={titleId} className={styles.title} tabIndex={-1} data-autofocus>{labels.preferencesTitle}</h2>
              <button type="button" className={styles.iconButton} aria-label={labels.close} onClick={() => { playUiSound("close"); closePanel(true); }}><UiIcon iconRole="close" size={16} strokeWidth={1.75} aria-hidden="true" /></button>
            </div>
            <p id={descriptionId} className={styles.description}>{labels.preferencesDescription}{policyLink ? <> {policyLink}</> : null}</p>
            <ul className={styles.list}>
              {categories.map(category => {
                const switchId = `${uid}-${category.id}`, hintId = `${switchId}-hint`;
                return <li key={category.id} className={styles.row}>
                  <div className={styles.rowText}>
                    <label htmlFor={category.required ? undefined : switchId} className={styles.rowLabel}>{category.label}</label>
                    {category.description ? <p id={hintId} className={styles.rowHint}>{category.description}</p> : null}
                  </div>
                  {category.required
                    ? <span className={styles.always}>{labels.alwaysOn}</span>
                    : <Switch id={switchId} aria-describedby={category.description ? hintId : undefined} checked={!!draft[category.id]}
                      onCheckedChange={checked => setDraft(current => ({ ...current, [category.id]: checked }))} />}
                </li>;
              })}
            </ul>
            <div className={styles.panelActions}>
              <Button size="sm" variant="secondary" onClick={() => choose("reject-all")}>{labels.rejectAll}</Button>
              <Button size="sm" variant="secondary" onClick={() => choose("accept-all")}>{labels.acceptAll}</Button>
              <Button size="sm" variant="primary" className={styles.save} onClick={() => choose("save")} aria-describedby={`${uid}-count`}>{labels.save}</Button>
              <span id={`${uid}-count`} className={styles.srOnly}>{`선택 항목 ${optional.length}개 중 ${allowedCount}개를 허용했어요`}</span>
            </div>
          </Face> : null}

          {mode === "chip" ? <Face key="chip" id="chip" width={CHIP} side={chipSide} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === "chip"} className={styles.chipFace}>
            <button type="button" className={styles.chip} aria-label={labels.reopen} aria-haspopup="dialog" data-autofocus onClick={() => { playUiSound("open"); openPanel(true); }}>
              <HugeiconsIcon icon={CookieIcon} size={20} strokeWidth={1.75} aria-hidden="true" />
            </button>
          </Face> : null}
        </AnimatePresence>
      </motion.div> : null}
    </AnimatePresence>
  </div>;
});

export default CookieConsent;

Used items